diff options
110 files changed, 6583 insertions, 2603 deletions
diff --git a/TalerWalletT.entitlements b/GNU Taler.entitlements index dfdca9d..dfdca9d 100644 --- a/TalerWalletT.entitlements +++ b/GNU Taler.entitlements @@ -12,7 +12,7 @@ <key>CFBundleTypeRole</key> <string>Viewer</string> <key>CFBundleURLName</key> - <string>com.taler-systems.talerwallet15</string> + <string>com.taler-systems.gnutalerwallet09</string> <key>CFBundleURLSchemes</key> <array> <string>taler</string> @@ -24,17 +24,12 @@ </array> <key>ITSAppUsesNonExemptEncryption</key> <false/> - <key>UIApplicationSceneManifest</key> - <dict> - <key>UIApplicationSupportsMultipleScenes</key> - <true/> - <key>UISceneConfigurations</key> - <dict/> - </dict> <key>UIBackgroundModes</key> <array> <string>fetch</string> <string>processing</string> </array> + <key>UIFileSharingEnabled</key> + <true/> </dict> </plist> diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..ed661f4 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,12 @@ +Copyright ©2022-23 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/> diff --git a/TalerTests/WalletBackendTests.swift b/TalerTests/WalletBackendTests.swift index 4a09133..20c8788 100644 --- a/TalerTests/WalletBackendTests.swift +++ b/TalerTests/WalletBackendTests.swift @@ -1,17 +1,6 @@ /* - * 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/> + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md */ import XCTest @testable import Taler diff --git a/TalerUITests/TalerUITests.swift b/TalerUITests/TalerUITests.swift index 24b772e..9c57fef 100644 --- a/TalerUITests/TalerUITests.swift +++ b/TalerUITests/TalerUITests.swift @@ -1,19 +1,7 @@ /* - * 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/> + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md */ - import XCTest class TalerUITests: XCTestCase { diff --git a/TalerWallet.xcodeproj/project.pbxproj b/TalerWallet.xcodeproj/project.pbxproj index 2052f9a..66a3b04 100644 --- a/TalerWallet.xcodeproj/project.pbxproj +++ b/TalerWallet.xcodeproj/project.pbxproj @@ -3,11 +3,32 @@ archiveVersion = 1; classes = { }; - objectVersion = 52; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ + 4E16E12329F3BB99008B9C86 /* CurrencyFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E16E12229F3BB99008B9C86 /* CurrencyFormatter.swift */; }; + 4E363CBC2A237E0900D7E98C /* URL+iban.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E363CBB2A237E0900D7E98C /* URL+iban.swift */; }; + 4E363CBE2A23CB2100D7E98C /* AnyTransition+backslide.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E363CBD2A23CB2100D7E98C /* AnyTransition+backslide.swift */; }; + 4E363CC02A24754200D7E98C /* Settings.bundle in Resources */ = {isa = PBXBuildFile; fileRef = 4E363CBF2A24754200D7E98C /* Settings.bundle */; }; + 4E363CC22A2621C200D7E98C /* LocalizedAlertError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E363CC12A2621C200D7E98C /* LocalizedAlertError.swift */; }; + 4E40E0BE29F25ABB00B85369 /* SendAmount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E40E0BD29F25ABB00B85369 /* SendAmount.swift */; }; + 4E50B3502A1BEE8000F9F01C /* ManualWithdraw.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E50B34F2A1BEE8000F9F01C /* ManualWithdraw.swift */; }; + 4E53A33729F50B7B00830EC2 /* CurrencyField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E53A33629F50B7B00830EC2 /* CurrencyField.swift */; }; + 4E6EDD852A3615BE0031D520 /* ManualDetails.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E6EDD842A3615BE0031D520 /* ManualDetails.swift */; }; + 4E6EDD872A363D8D0031D520 /* ListStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E6EDD862A363D8D0031D520 /* ListStyle.swift */; }; + 4E753A062A0952F8002D9328 /* DebugViewC.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E753A052A0952F7002D9328 /* DebugViewC.swift */; }; + 4E753A082A0B6A5F002D9328 /* ShareSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E753A072A0B6A5F002D9328 /* ShareSheet.swift */; }; + 4E7940DE29FC307C00A9AEA1 /* SendPurpose.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E7940DD29FC307C00A9AEA1 /* SendPurpose.swift */; }; + 4E87C8732A31CB7F001C6406 /* TransactionsEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E87C8722A31CB7F001C6406 /* TransactionsEmptyView.swift */; }; + 4E87C8752A34B411001C6406 /* UncompletedRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E87C8742A34B411001C6406 /* UncompletedRowView.swift */; }; + 4E8E25332A1CD39700A27BFA /* EqualIconWidthDomain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E8E25322A1CD39700A27BFA /* EqualIconWidthDomain.swift */; }; + 4E9320432A14F6EA00A87B0E /* WalletColors.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9320422A14F6EA00A87B0E /* WalletColors.swift */; }; + 4E9320452A1645B600A87B0E /* RequestPayment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9320442A1645B600A87B0E /* RequestPayment.swift */; }; + 4E9320472A164BC700A87B0E /* ReceivePurpose.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E9320462A164BC700A87B0E /* ReceivePurpose.swift */; }; 4EA1ABBE29A3833A008821EA /* PublicConstants.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA1ABBD29A3833A008821EA /* PublicConstants.swift */; }; + 4EA551252A2C923600FEC9A8 /* CurrencyInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EA551242A2C923600FEC9A8 /* CurrencyInputView.swift */; }; + 4EAD117629F672FA008EDD0B /* KeyboardResponder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EAD117529F672FA008EDD0B /* KeyboardResponder.swift */; }; 4EB094D629896CD20043A8A1 /* TalerWalletTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB094D429896CD20043A8A1 /* TalerWalletTests.swift */; }; 4EB094D729896CD20043A8A1 /* WalletBackendTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB094D529896CD20043A8A1 /* WalletBackendTests.swift */; }; 4EB094DC29896D030043A8A1 /* TalerWalletUITestsLaunchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB094D929896D030043A8A1 /* TalerWalletUITestsLaunchTests.swift */; }; @@ -23,7 +44,7 @@ 4EB0950A2989CB7C0043A8A1 /* TalerStrings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB095072989CB7C0043A8A1 /* TalerStrings.swift */; }; 4EB0950B2989CB7C0043A8A1 /* View+dismissTop.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB095082989CB7C0043A8A1 /* View+dismissTop.swift */; }; 4EB0950E2989CB9A0043A8A1 /* quickjs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB0950D2989CB9A0043A8A1 /* quickjs.swift */; }; - 4EB095152989CBB00043A8A1 /* ExchangeTestModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB095102989CBB00043A8A1 /* ExchangeTestModel.swift */; }; + 4EB095152989CBB00043A8A1 /* SettingsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB095102989CBB00043A8A1 /* SettingsModel.swift */; }; 4EB095162989CBB00043A8A1 /* WalletModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB095112989CBB00043A8A1 /* WalletModel.swift */; }; 4EB095192989CBB00043A8A1 /* WalletInitModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB095142989CBB00043A8A1 /* WalletInitModel.swift */; }; 4EB0951F2989CBCB0043A8A1 /* WalletBackendRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB0951B2989CBCB0043A8A1 /* WalletBackendRequest.swift */; }; @@ -38,24 +59,24 @@ 4EB095542989CBFE0043A8A1 /* PaymentURIModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB0952C2989CBFE0043A8A1 /* PaymentURIModel.swift */; }; 4EB095552989CBFE0043A8A1 /* PaymentURIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB0952D2989CBFE0043A8A1 /* PaymentURIView.swift */; }; 4EB095562989CBFE0043A8A1 /* TransactionsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB0952F2989CBFE0043A8A1 /* TransactionsListView.swift */; }; - 4EB095572989CBFE0043A8A1 /* TransactionRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB095302989CBFE0043A8A1 /* TransactionRow.swift */; }; - 4EB095582989CBFE0043A8A1 /* TransactionDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB095312989CBFE0043A8A1 /* TransactionDetail.swift */; }; + 4EB095572989CBFE0043A8A1 /* TransactionRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB095302989CBFE0043A8A1 /* TransactionRowView.swift */; }; + 4EB095582989CBFE0043A8A1 /* TransactionDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB095312989CBFE0043A8A1 /* TransactionDetailView.swift */; }; 4EB095592989CBFE0043A8A1 /* TransactionsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB095322989CBFE0043A8A1 /* TransactionsModel.swift */; }; 4EB0955A2989CBFE0043A8A1 /* URLSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB095332989CBFE0043A8A1 /* URLSheet.swift */; }; 4EB0955B2989CBFE0043A8A1 /* BalancesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB095352989CBFE0043A8A1 /* BalancesModel.swift */; }; - 4EB0955C2989CBFE0043A8A1 /* BalanceRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB095362989CBFE0043A8A1 /* BalanceRow.swift */; }; - 4EB0955D2989CBFE0043A8A1 /* CurrenciesListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB095372989CBFE0043A8A1 /* CurrenciesListView.swift */; }; - 4EB0955E2989CBFE0043A8A1 /* PendingRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB095382989CBFE0043A8A1 /* PendingRow.swift */; }; + 4EB0955C2989CBFE0043A8A1 /* BalanceRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB095362989CBFE0043A8A1 /* BalanceRowView.swift */; }; + 4EB0955D2989CBFE0043A8A1 /* BalancesListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB095372989CBFE0043A8A1 /* BalancesListView.swift */; }; + 4EB0955E2989CBFE0043A8A1 /* PendingRowView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB095382989CBFE0043A8A1 /* PendingRowView.swift */; }; 4EB0955F2989CBFE0043A8A1 /* WalletEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB095392989CBFE0043A8A1 /* WalletEmptyView.swift */; }; - 4EB095602989CBFE0043A8A1 /* CurrencyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB0953A2989CBFE0043A8A1 /* CurrencyView.swift */; }; + 4EB095602989CBFE0043A8A1 /* BalancesSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB0953A2989CBFE0043A8A1 /* BalancesSectionView.swift */; }; 4EB095612989CBFE0043A8A1 /* WithdrawURIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB0953C2989CBFE0043A8A1 /* WithdrawURIView.swift */; }; - 4EB095622989CBFE0043A8A1 /* WithdrawURIModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB0953D2989CBFE0043A8A1 /* WithdrawURIModel.swift */; }; + 4EB095622989CBFE0043A8A1 /* WithdrawModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB0953D2989CBFE0043A8A1 /* WithdrawModel.swift */; }; 4EB095632989CBFE0043A8A1 /* WithdrawAcceptView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB0953E2989CBFE0043A8A1 /* WithdrawAcceptView.swift */; }; 4EB095642989CBFE0043A8A1 /* WithdrawProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB0953F2989CBFE0043A8A1 /* WithdrawProgressView.swift */; }; 4EB095652989CBFE0043A8A1 /* WithdrawTOSView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB095402989CBFE0043A8A1 /* WithdrawTOSView.swift */; }; 4EB095662989CBFE0043A8A1 /* SideBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB095422989CBFE0043A8A1 /* SideBarView.swift */; }; 4EB095672989CBFE0043A8A1 /* LaunchAnimationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB095432989CBFE0043A8A1 /* LaunchAnimationView.swift */; }; - 4EB095682989CBFE0043A8A1 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB095442989CBFE0043A8A1 /* ContentView.swift */; }; + 4EB095682989CBFE0043A8A1 /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB095442989CBFE0043A8A1 /* MainView.swift */; }; 4EB095692989CBFE0043A8A1 /* ErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB095452989CBFE0043A8A1 /* ErrorView.swift */; }; 4EB0956A2989CBFE0043A8A1 /* Buttons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB095472989CBFE0043A8A1 /* Buttons.swift */; }; 4EB0956B2989CBFE0043A8A1 /* TextFieldAlert.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB095482989CBFE0043A8A1 /* TextFieldAlert.swift */; }; @@ -64,6 +85,17 @@ 4EB0956E2989CBFE0043A8A1 /* PendingModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB0954C2989CBFE0043A8A1 /* PendingModel.swift */; }; 4EB0956F2989CBFE0043A8A1 /* PendingOpView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB0954D2989CBFE0043A8A1 /* PendingOpView.swift */; }; 4EB095702989CBFE0043A8A1 /* PendingOpsListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB0954E2989CBFE0043A8A1 /* PendingOpsListView.swift */; }; + 4EB3136129FEE79B007D68BC /* SendNow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB3136029FEE79B007D68BC /* SendNow.swift */; }; + 4EB431672A1E55C700C5690E /* ManualWithdrawDone.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EB431662A1E55C700C5690E /* ManualWithdrawDone.swift */; }; + 4EC90C782A1B528B0071DC58 /* ExchangeSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EC90C772A1B528B0071DC58 /* ExchangeSectionView.swift */; }; + 4ECB62802A0BA6DF004ABBB7 /* Peer2peerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ECB627F2A0BA6DF004ABBB7 /* Peer2peerModel.swift */; }; + 4ECB62822A0BB01D004ABBB7 /* SelectDays.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ECB62812A0BB01D004ABBB7 /* SelectDays.swift */; }; + 4ED2F94B2A278F5100453B40 /* ThreeAmounts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4ED2F94A2A278F5100453B40 /* ThreeAmounts.swift */; }; + 4EEC157329F8242800D46A03 /* QRGeneratorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EEC157229F8242800D46A03 /* QRGeneratorView.swift */; }; + 4EEC157629F8ECBF00D46A03 /* CodeScanner in Frameworks */ = {isa = PBXBuildFile; productRef = 4EEC157529F8ECBF00D46A03 /* CodeScanner */; }; + 4EEC157829F9032900D46A03 /* Sheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EEC157729F9032900D46A03 /* Sheet.swift */; }; + 4EEC157A29F9427F00D46A03 /* QRSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EEC157929F9427F00D46A03 /* QRSheet.swift */; }; + 4EF840A72A0B85F400EE0D47 /* CopyShare.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EF840A62A0B85F400EE0D47 /* CopyShare.swift */; }; ABC13AA32859962800D23185 /* taler-swift in Frameworks */ = {isa = PBXBuildFile; productRef = ABC13AA22859962800D23185 /* taler-swift */; }; ABE97B1D286D82BF00580772 /* AnyCodable in Frameworks */ = {isa = PBXBuildFile; productRef = ABE97B1C286D82BF00580772 /* AnyCodable */; }; /* End PBXBuildFile section */ @@ -98,8 +130,30 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ - 4E3AE7EF29A7E8F40070BEC4 /* TalerWalletT.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TalerWalletT.entitlements; sourceTree = "<group>"; }; + 4E16E12229F3BB99008B9C86 /* CurrencyFormatter.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CurrencyFormatter.swift; sourceTree = "<group>"; }; + 4E363CBB2A237E0900D7E98C /* URL+iban.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "URL+iban.swift"; sourceTree = "<group>"; }; + 4E363CBD2A23CB2100D7E98C /* AnyTransition+backslide.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "AnyTransition+backslide.swift"; sourceTree = "<group>"; }; + 4E363CBF2A24754200D7E98C /* Settings.bundle */ = {isa = PBXFileReference; lastKnownFileType = "wrapper.plug-in"; path = Settings.bundle; sourceTree = "<group>"; }; + 4E363CC12A2621C200D7E98C /* LocalizedAlertError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LocalizedAlertError.swift; sourceTree = "<group>"; }; + 4E3AE7EF29A7E8F40070BEC4 /* Taler Wallet.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "Taler Wallet.entitlements"; sourceTree = "<group>"; }; + 4E40E0BD29F25ABB00B85369 /* SendAmount.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; name = SendAmount.swift; path = TalerWallet1/Views/Peer2peer/SendAmount.swift; sourceTree = SOURCE_ROOT; }; + 4E50B34F2A1BEE8000F9F01C /* ManualWithdraw.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ManualWithdraw.swift; sourceTree = "<group>"; }; + 4E53A33629F50B7B00830EC2 /* CurrencyField.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CurrencyField.swift; sourceTree = "<group>"; }; + 4E6EDD842A3615BE0031D520 /* ManualDetails.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ManualDetails.swift; sourceTree = "<group>"; }; + 4E6EDD862A363D8D0031D520 /* ListStyle.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ListStyle.swift; sourceTree = "<group>"; }; + 4E753A042A08E720002D9328 /* transactions.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = transactions.json; sourceTree = "<group>"; }; + 4E753A052A0952F7002D9328 /* DebugViewC.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DebugViewC.swift; sourceTree = "<group>"; }; + 4E753A072A0B6A5F002D9328 /* ShareSheet.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ShareSheet.swift; sourceTree = "<group>"; }; + 4E7940DD29FC307C00A9AEA1 /* SendPurpose.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SendPurpose.swift; sourceTree = "<group>"; }; + 4E87C8722A31CB7F001C6406 /* TransactionsEmptyView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransactionsEmptyView.swift; sourceTree = "<group>"; }; + 4E87C8742A34B411001C6406 /* UncompletedRowView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UncompletedRowView.swift; sourceTree = "<group>"; }; + 4E8E25322A1CD39700A27BFA /* EqualIconWidthDomain.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EqualIconWidthDomain.swift; sourceTree = "<group>"; }; + 4E9320422A14F6EA00A87B0E /* WalletColors.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WalletColors.swift; sourceTree = "<group>"; }; + 4E9320442A1645B600A87B0E /* RequestPayment.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RequestPayment.swift; sourceTree = "<group>"; }; + 4E9320462A164BC700A87B0E /* ReceivePurpose.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ReceivePurpose.swift; sourceTree = "<group>"; }; 4EA1ABBD29A3833A008821EA /* PublicConstants.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublicConstants.swift; sourceTree = "<group>"; }; + 4EA551242A2C923600FEC9A8 /* CurrencyInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrencyInputView.swift; sourceTree = "<group>"; }; + 4EAD117529F672FA008EDD0B /* KeyboardResponder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyboardResponder.swift; sourceTree = "<group>"; }; 4EB094D429896CD20043A8A1 /* TalerWalletTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TalerWalletTests.swift; sourceTree = "<group>"; }; 4EB094D529896CD20043A8A1 /* WalletBackendTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WalletBackendTests.swift; sourceTree = "<group>"; }; 4EB094D929896D030043A8A1 /* TalerWalletUITestsLaunchTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TalerWalletUITestsLaunchTests.swift; sourceTree = "<group>"; }; @@ -115,7 +169,7 @@ 4EB095072989CB7C0043A8A1 /* TalerStrings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TalerStrings.swift; sourceTree = "<group>"; }; 4EB095082989CB7C0043A8A1 /* View+dismissTop.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "View+dismissTop.swift"; sourceTree = "<group>"; }; 4EB0950D2989CB9A0043A8A1 /* quickjs.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = quickjs.swift; sourceTree = "<group>"; }; - 4EB095102989CBB00043A8A1 /* ExchangeTestModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExchangeTestModel.swift; sourceTree = "<group>"; }; + 4EB095102989CBB00043A8A1 /* SettingsModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SettingsModel.swift; sourceTree = "<group>"; }; 4EB095112989CBB00043A8A1 /* WalletModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WalletModel.swift; sourceTree = "<group>"; }; 4EB095142989CBB00043A8A1 /* WalletInitModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WalletInitModel.swift; sourceTree = "<group>"; }; 4EB0951B2989CBCB0043A8A1 /* WalletBackendRequest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WalletBackendRequest.swift; sourceTree = "<group>"; }; @@ -130,24 +184,24 @@ 4EB0952C2989CBFE0043A8A1 /* PaymentURIModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentURIModel.swift; sourceTree = "<group>"; }; 4EB0952D2989CBFE0043A8A1 /* PaymentURIView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PaymentURIView.swift; sourceTree = "<group>"; }; 4EB0952F2989CBFE0043A8A1 /* TransactionsListView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransactionsListView.swift; sourceTree = "<group>"; }; - 4EB095302989CBFE0043A8A1 /* TransactionRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransactionRow.swift; sourceTree = "<group>"; }; - 4EB095312989CBFE0043A8A1 /* TransactionDetail.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransactionDetail.swift; sourceTree = "<group>"; }; + 4EB095302989CBFE0043A8A1 /* TransactionRowView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransactionRowView.swift; sourceTree = "<group>"; }; + 4EB095312989CBFE0043A8A1 /* TransactionDetailView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransactionDetailView.swift; sourceTree = "<group>"; }; 4EB095322989CBFE0043A8A1 /* TransactionsModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TransactionsModel.swift; sourceTree = "<group>"; }; 4EB095332989CBFE0043A8A1 /* URLSheet.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = URLSheet.swift; sourceTree = "<group>"; }; 4EB095352989CBFE0043A8A1 /* BalancesModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BalancesModel.swift; sourceTree = "<group>"; }; - 4EB095362989CBFE0043A8A1 /* BalanceRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BalanceRow.swift; sourceTree = "<group>"; }; - 4EB095372989CBFE0043A8A1 /* CurrenciesListView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CurrenciesListView.swift; sourceTree = "<group>"; }; - 4EB095382989CBFE0043A8A1 /* PendingRow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PendingRow.swift; sourceTree = "<group>"; }; + 4EB095362989CBFE0043A8A1 /* BalanceRowView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BalanceRowView.swift; sourceTree = "<group>"; }; + 4EB095372989CBFE0043A8A1 /* BalancesListView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BalancesListView.swift; sourceTree = "<group>"; }; + 4EB095382989CBFE0043A8A1 /* PendingRowView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PendingRowView.swift; sourceTree = "<group>"; }; 4EB095392989CBFE0043A8A1 /* WalletEmptyView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WalletEmptyView.swift; sourceTree = "<group>"; }; - 4EB0953A2989CBFE0043A8A1 /* CurrencyView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CurrencyView.swift; sourceTree = "<group>"; }; + 4EB0953A2989CBFE0043A8A1 /* BalancesSectionView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BalancesSectionView.swift; sourceTree = "<group>"; }; 4EB0953C2989CBFE0043A8A1 /* WithdrawURIView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WithdrawURIView.swift; sourceTree = "<group>"; }; - 4EB0953D2989CBFE0043A8A1 /* WithdrawURIModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WithdrawURIModel.swift; sourceTree = "<group>"; }; + 4EB0953D2989CBFE0043A8A1 /* WithdrawModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WithdrawModel.swift; sourceTree = "<group>"; }; 4EB0953E2989CBFE0043A8A1 /* WithdrawAcceptView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WithdrawAcceptView.swift; sourceTree = "<group>"; }; 4EB0953F2989CBFE0043A8A1 /* WithdrawProgressView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WithdrawProgressView.swift; sourceTree = "<group>"; }; 4EB095402989CBFE0043A8A1 /* WithdrawTOSView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WithdrawTOSView.swift; sourceTree = "<group>"; }; 4EB095422989CBFE0043A8A1 /* SideBarView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SideBarView.swift; sourceTree = "<group>"; }; 4EB095432989CBFE0043A8A1 /* LaunchAnimationView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LaunchAnimationView.swift; sourceTree = "<group>"; }; - 4EB095442989CBFE0043A8A1 /* ContentView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; + 4EB095442989CBFE0043A8A1 /* MainView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; }; 4EB095452989CBFE0043A8A1 /* ErrorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ErrorView.swift; sourceTree = "<group>"; }; 4EB095472989CBFE0043A8A1 /* Buttons.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Buttons.swift; sourceTree = "<group>"; }; 4EB095482989CBFE0043A8A1 /* TextFieldAlert.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TextFieldAlert.swift; sourceTree = "<group>"; }; @@ -156,8 +210,18 @@ 4EB0954C2989CBFE0043A8A1 /* PendingModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PendingModel.swift; sourceTree = "<group>"; }; 4EB0954D2989CBFE0043A8A1 /* PendingOpView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PendingOpView.swift; sourceTree = "<group>"; }; 4EB0954E2989CBFE0043A8A1 /* PendingOpsListView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PendingOpsListView.swift; sourceTree = "<group>"; }; + 4EB3136029FEE79B007D68BC /* SendNow.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SendNow.swift; sourceTree = "<group>"; }; + 4EB431662A1E55C700C5690E /* ManualWithdrawDone.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualWithdrawDone.swift; sourceTree = "<group>"; }; + 4EC90C772A1B528B0071DC58 /* ExchangeSectionView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ExchangeSectionView.swift; sourceTree = "<group>"; }; + 4ECB627F2A0BA6DF004ABBB7 /* Peer2peerModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Peer2peerModel.swift; sourceTree = "<group>"; }; + 4ECB62812A0BB01D004ABBB7 /* SelectDays.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SelectDays.swift; sourceTree = "<group>"; }; + 4ED2F94A2A278F5100453B40 /* ThreeAmounts.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThreeAmounts.swift; sourceTree = "<group>"; }; + 4EEC157229F8242800D46A03 /* QRGeneratorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QRGeneratorView.swift; sourceTree = "<group>"; }; + 4EEC157729F9032900D46A03 /* Sheet.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Sheet.swift; sourceTree = "<group>"; }; + 4EEC157929F9427F00D46A03 /* QRSheet.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = QRSheet.swift; sourceTree = "<group>"; }; + 4EF840A62A0B85F400EE0D47 /* CopyShare.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CopyShare.swift; sourceTree = "<group>"; }; AB710490285995B6008B04F0 /* taler-swift */ = {isa = PBXFileReference; lastKnownFileType = text; path = "taler-swift"; sourceTree = SOURCE_ROOT; }; - D14AFD1D24D232B300C51073 /* TalerWalletT.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = TalerWalletT.app; sourceTree = BUILT_PRODUCTS_DIR; }; + D14AFD1D24D232B300C51073 /* GNU Taler.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "GNU Taler.app"; sourceTree = BUILT_PRODUCTS_DIR; }; D14AFD3324D232B500C51073 /* TalerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TalerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D14AFD3E24D232B500C51073 /* TalerUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = TalerUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -171,6 +235,7 @@ 4EB094FD29897D280043A8A1 /* SymLog in Frameworks */, 4EB094F829897CA20043A8A1 /* FTalerWalletcore.framework in Frameworks */, ABC13AA32859962800D23185 /* taler-swift in Frameworks */, + 4EEC157629F8ECBF00D46A03 /* CodeScanner in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -198,9 +263,10 @@ 4EB095232989CBFE0043A8A1 /* Views */, 4EB0950F2989CBB00043A8A1 /* Model */, 4EB0951A2989CBCB0043A8A1 /* Backend */, - 4EB0950C2989CB9A0043A8A1 /* Quickjs */, 4EB095052989CB7C0043A8A1 /* Helper */, + 4EB0950C2989CB9A0043A8A1 /* Quickjs */, 4EB094EF298979D30043A8A1 /* Assets.xcassets */, + 4E363CBF2A24754200D7E98C /* Settings.bundle */, 4EB094F529897A9A0043A8A1 /* Preview Content */, ); path = TalerWallet1; @@ -210,6 +276,7 @@ isa = PBXGroup; children = ( 4EB094F329897A510043A8A1 /* Preview Assets.xcassets */, + 4E753A042A08E720002D9328 /* transactions.json */, ); path = "Preview Content"; sourceTree = "<group>"; @@ -227,6 +294,8 @@ children = ( 4EB094EC298979620043A8A1 /* TalerWallet1App.swift */, 4EB095012989C9BC0043A8A1 /* Controller.swift */, + 4EA1ABBD29A3833A008821EA /* PublicConstants.swift */, + 4E753A052A0952F7002D9328 /* DebugViewC.swift */, ); path = Controllers; sourceTree = "<group>"; @@ -234,10 +303,16 @@ 4EB095052989CB7C0043A8A1 /* Helper */ = { isa = PBXGroup; children = ( + 4E363CBD2A23CB2100D7E98C /* AnyTransition+backslide.swift */, + 4E16E12229F3BB99008B9C86 /* CurrencyFormatter.swift */, + 4EAD117529F672FA008EDD0B /* KeyboardResponder.swift */, + 4E363CC12A2621C200D7E98C /* LocalizedAlertError.swift */, 4EB095062989CB7C0043A8A1 /* TalerDater.swift */, 4EB095072989CB7C0043A8A1 /* TalerStrings.swift */, 4EB095082989CB7C0043A8A1 /* View+dismissTop.swift */, - 4EA1ABBD29A3833A008821EA /* PublicConstants.swift */, + 4E363CBB2A237E0900D7E98C /* URL+iban.swift */, + 4E9320422A14F6EA00A87B0E /* WalletColors.swift */, + 4E8E25322A1CD39700A27BFA /* EqualIconWidthDomain.swift */, ); path = Helper; sourceTree = "<group>"; @@ -253,9 +328,16 @@ 4EB0950F2989CBB00043A8A1 /* Model */ = { isa = PBXGroup; children = ( - 4EB095102989CBB00043A8A1 /* ExchangeTestModel.swift */, 4EB095112989CBB00043A8A1 /* WalletModel.swift */, 4EB095142989CBB00043A8A1 /* WalletInitModel.swift */, + 4EB095352989CBFE0043A8A1 /* BalancesModel.swift */, + 4EB095282989CBFE0043A8A1 /* ExchangeModel.swift */, + 4ECB627F2A0BA6DF004ABBB7 /* Peer2peerModel.swift */, + 4EB0954C2989CBFE0043A8A1 /* PendingModel.swift */, + 4EB0952C2989CBFE0043A8A1 /* PaymentURIModel.swift */, + 4EB095102989CBB00043A8A1 /* SettingsModel.swift */, + 4EB095322989CBFE0043A8A1 /* TransactionsModel.swift */, + 4EB0953D2989CBFE0043A8A1 /* WithdrawModel.swift */, ); path = Model; sourceTree = "<group>"; @@ -275,15 +357,15 @@ isa = PBXGroup; children = ( 4EB095412989CBFE0043A8A1 /* Main */, - 4EB095242989CBFE0043A8A1 /* Settings */, + 4EB095342989CBFE0043A8A1 /* Balances */, + 4EB0952E2989CBFE0043A8A1 /* Transactions */, 4EB095272989CBFE0043A8A1 /* Exchange */, + 4EB095242989CBFE0043A8A1 /* Settings */, + 4ECB627E2A0BA4DA004ABBB7 /* Peer2peer */, + 4EEC157129F7188B00D46A03 /* Sheets */, + 4EB0953B2989CBFE0043A8A1 /* WithdrawBankIntegrated */, 4EB0952A2989CBFE0043A8A1 /* Payment */, - 4EB0952E2989CBFE0043A8A1 /* Transactions */, - 4EB095332989CBFE0043A8A1 /* URLSheet.swift */, - 4EB095342989CBFE0043A8A1 /* Balances */, - 4EB0953B2989CBFE0043A8A1 /* Withdraw */, 4EB095462989CBFE0043A8A1 /* HelperViews */, - 4EB0954B2989CBFE0043A8A1 /* Pending */, ); path = Views; sourceTree = "<group>"; @@ -293,6 +375,7 @@ children = ( 4EB095252989CBFE0043A8A1 /* SettingsView.swift */, 4EB095262989CBFE0043A8A1 /* SettingsItem.swift */, + 4EB0954B2989CBFE0043A8A1 /* Pending */, ); path = Settings; sourceTree = "<group>"; @@ -300,8 +383,10 @@ 4EB095272989CBFE0043A8A1 /* Exchange */ = { isa = PBXGroup; children = ( - 4EB095282989CBFE0043A8A1 /* ExchangeModel.swift */, 4EB095292989CBFE0043A8A1 /* ExchangeListView.swift */, + 4EC90C772A1B528B0071DC58 /* ExchangeSectionView.swift */, + 4E50B34F2A1BEE8000F9F01C /* ManualWithdraw.swift */, + 4EB431662A1E55C700C5690E /* ManualWithdrawDone.swift */, ); path = Exchange; sourceTree = "<group>"; @@ -309,9 +394,8 @@ 4EB0952A2989CBFE0043A8A1 /* Payment */ = { isa = PBXGroup; children = ( - 4EB0952B2989CBFE0043A8A1 /* PaymentAcceptView.swift */, - 4EB0952C2989CBFE0043A8A1 /* PaymentURIModel.swift */, 4EB0952D2989CBFE0043A8A1 /* PaymentURIView.swift */, + 4EB0952B2989CBFE0043A8A1 /* PaymentAcceptView.swift */, ); path = Payment; sourceTree = "<group>"; @@ -320,9 +404,11 @@ isa = PBXGroup; children = ( 4EB0952F2989CBFE0043A8A1 /* TransactionsListView.swift */, - 4EB095302989CBFE0043A8A1 /* TransactionRow.swift */, - 4EB095312989CBFE0043A8A1 /* TransactionDetail.swift */, - 4EB095322989CBFE0043A8A1 /* TransactionsModel.swift */, + 4EB095302989CBFE0043A8A1 /* TransactionRowView.swift */, + 4EB095312989CBFE0043A8A1 /* TransactionDetailView.swift */, + 4E87C8722A31CB7F001C6406 /* TransactionsEmptyView.swift */, + 4E6EDD842A3615BE0031D520 /* ManualDetails.swift */, + 4ED2F94A2A278F5100453B40 /* ThreeAmounts.swift */, ); path = Transactions; sourceTree = "<group>"; @@ -330,35 +416,33 @@ 4EB095342989CBFE0043A8A1 /* Balances */ = { isa = PBXGroup; children = ( - 4EB095352989CBFE0043A8A1 /* BalancesModel.swift */, - 4EB095362989CBFE0043A8A1 /* BalanceRow.swift */, - 4EB095372989CBFE0043A8A1 /* CurrenciesListView.swift */, - 4EB095382989CBFE0043A8A1 /* PendingRow.swift */, - 4EB095392989CBFE0043A8A1 /* WalletEmptyView.swift */, - 4EB0953A2989CBFE0043A8A1 /* CurrencyView.swift */, + 4EB095372989CBFE0043A8A1 /* BalancesListView.swift */, + 4EB0953A2989CBFE0043A8A1 /* BalancesSectionView.swift */, + 4EB095362989CBFE0043A8A1 /* BalanceRowView.swift */, + 4EB095382989CBFE0043A8A1 /* PendingRowView.swift */, + 4E87C8742A34B411001C6406 /* UncompletedRowView.swift */, ); path = Balances; sourceTree = "<group>"; }; - 4EB0953B2989CBFE0043A8A1 /* Withdraw */ = { + 4EB0953B2989CBFE0043A8A1 /* WithdrawBankIntegrated */ = { isa = PBXGroup; children = ( 4EB0953C2989CBFE0043A8A1 /* WithdrawURIView.swift */, - 4EB0953D2989CBFE0043A8A1 /* WithdrawURIModel.swift */, 4EB0953E2989CBFE0043A8A1 /* WithdrawAcceptView.swift */, 4EB0953F2989CBFE0043A8A1 /* WithdrawProgressView.swift */, 4EB095402989CBFE0043A8A1 /* WithdrawTOSView.swift */, ); - path = Withdraw; + path = WithdrawBankIntegrated; sourceTree = "<group>"; }; 4EB095412989CBFE0043A8A1 /* Main */ = { isa = PBXGroup; children = ( - 4EB095442989CBFE0043A8A1 /* ContentView.swift */, + 4EB095442989CBFE0043A8A1 /* MainView.swift */, 4EB095422989CBFE0043A8A1 /* SideBarView.swift */, - 4EB095432989CBFE0043A8A1 /* LaunchAnimationView.swift */, 4EB095452989CBFE0043A8A1 /* ErrorView.swift */, + 4EB095392989CBFE0043A8A1 /* WalletEmptyView.swift */, ); path = Main; sourceTree = "<group>"; @@ -367,9 +451,16 @@ isa = PBXGroup; children = ( 4EB095472989CBFE0043A8A1 /* Buttons.swift */, + 4EF840A62A0B85F400EE0D47 /* CopyShare.swift */, + 4ECB62812A0BB01D004ABBB7 /* SelectDays.swift */, + 4E53A33629F50B7B00830EC2 /* CurrencyField.swift */, + 4EA551242A2C923600FEC9A8 /* CurrencyInputView.swift */, + 4EEC157229F8242800D46A03 /* QRGeneratorView.swift */, + 4E6EDD862A363D8D0031D520 /* ListStyle.swift */, 4EB095482989CBFE0043A8A1 /* TextFieldAlert.swift */, 4EB095492989CBFE0043A8A1 /* AmountView.swift */, 4EB0954A2989CBFE0043A8A1 /* LoadingView.swift */, + 4EB095432989CBFE0043A8A1 /* LaunchAnimationView.swift */, ); path = HelperViews; sourceTree = "<group>"; @@ -377,19 +468,41 @@ 4EB0954B2989CBFE0043A8A1 /* Pending */ = { isa = PBXGroup; children = ( - 4EB0954C2989CBFE0043A8A1 /* PendingModel.swift */, - 4EB0954D2989CBFE0043A8A1 /* PendingOpView.swift */, 4EB0954E2989CBFE0043A8A1 /* PendingOpsListView.swift */, + 4EB0954D2989CBFE0043A8A1 /* PendingOpView.swift */, ); path = Pending; sourceTree = "<group>"; }; + 4ECB627E2A0BA4DA004ABBB7 /* Peer2peer */ = { + isa = PBXGroup; + children = ( + 4E40E0BD29F25ABB00B85369 /* SendAmount.swift */, + 4E7940DD29FC307C00A9AEA1 /* SendPurpose.swift */, + 4EB3136029FEE79B007D68BC /* SendNow.swift */, + 4E9320442A1645B600A87B0E /* RequestPayment.swift */, + 4E9320462A164BC700A87B0E /* ReceivePurpose.swift */, + ); + path = Peer2peer; + sourceTree = "<group>"; + }; + 4EEC157129F7188B00D46A03 /* Sheets */ = { + isa = PBXGroup; + children = ( + 4EEC157729F9032900D46A03 /* Sheet.swift */, + 4EEC157929F9427F00D46A03 /* QRSheet.swift */, + 4E753A072A0B6A5F002D9328 /* ShareSheet.swift */, + 4EB095332989CBFE0043A8A1 /* URLSheet.swift */, + ); + path = Sheets; + sourceTree = "<group>"; + }; D14AFD1424D232B300C51073 = { isa = PBXGroup; children = ( - 4E3AE7EF29A7E8F40070BEC4 /* TalerWalletT.entitlements */, 4EB094EE298979840043A8A1 /* TalerWallet1 */, 4EB094E129896FED0043A8A1 /* Info.plist */, + 4E3AE7EF29A7E8F40070BEC4 /* Taler Wallet.entitlements */, AB710490285995B6008B04F0 /* taler-swift */, D14AFD3624D232B500C51073 /* TalerTests */, D14AFD4124D232B500C51073 /* TalerUITests */, @@ -401,7 +514,7 @@ D14AFD1E24D232B300C51073 /* Products */ = { isa = PBXGroup; children = ( - D14AFD1D24D232B300C51073 /* TalerWalletT.app */, + D14AFD1D24D232B300C51073 /* GNU Taler.app */, D14AFD3324D232B500C51073 /* TalerTests.xctest */, D14AFD3E24D232B500C51073 /* TalerUITests.xctest */, ); @@ -430,9 +543,9 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - D14AFD1C24D232B300C51073 /* TalerWalletT */ = { + D14AFD1C24D232B300C51073 /* Taler_Wallet */ = { isa = PBXNativeTarget; - buildConfigurationList = D14AFD4724D232B500C51073 /* Build configuration list for PBXNativeTarget "TalerWalletT" */; + buildConfigurationList = D14AFD4724D232B500C51073 /* Build configuration list for PBXNativeTarget "Taler_Wallet" */; buildPhases = ( D14AFD1924D232B300C51073 /* Sources */, D14AFD1A24D232B300C51073 /* Frameworks */, @@ -443,14 +556,15 @@ ); dependencies = ( ); - name = TalerWalletT; + name = Taler_Wallet; packageProductDependencies = ( ABC13AA22859962800D23185 /* taler-swift */, ABE97B1C286D82BF00580772 /* AnyCodable */, 4EB094FC29897D280043A8A1 /* SymLog */, + 4EEC157529F8ECBF00D46A03 /* CodeScanner */, ); productName = Taler; - productReference = D14AFD1D24D232B300C51073 /* TalerWalletT.app */; + productReference = D14AFD1D24D232B300C51073 /* GNU Taler.app */; productType = "com.apple.product-type.application"; }; D14AFD3224D232B500C51073 /* TalerTests */ = { @@ -527,12 +641,13 @@ packageReferences = ( ABE97B1B286D82BF00580772 /* XCRemoteSwiftPackageReference "AnyCodable" */, 4EB094FB29897D280043A8A1 /* XCRemoteSwiftPackageReference "SymLog" */, + 4EEC157429F8ECBF00D46A03 /* XCRemoteSwiftPackageReference "CodeScanner" */, ); productRefGroup = D14AFD1E24D232B300C51073 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( - D14AFD1C24D232B300C51073 /* TalerWalletT */, + D14AFD1C24D232B300C51073 /* Taler_Wallet */, D14AFD3224D232B500C51073 /* TalerTests */, D14AFD3D24D232B500C51073 /* TalerUITests */, ); @@ -545,6 +660,7 @@ buildActionMask = 2147483647; files = ( 4EB094F429897A510043A8A1 /* Preview Assets.xcassets in Resources */, + 4E363CC02A24754200D7E98C /* Settings.bundle in Resources */, 4EB094F0298979D30043A8A1 /* Assets.xcassets in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -570,43 +686,65 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 4ECB62822A0BB01D004ABBB7 /* SelectDays.swift in Sources */, 4EB095512989CBFE0043A8A1 /* ExchangeModel.swift in Sources */, 4EB095032989C9BC0043A8A1 /* Controller.swift in Sources */, - 4EB095682989CBFE0043A8A1 /* ContentView.swift in Sources */, + 4EB095682989CBFE0043A8A1 /* MainView.swift in Sources */, 4EB0956A2989CBFE0043A8A1 /* Buttons.swift in Sources */, - 4EB095602989CBFE0043A8A1 /* CurrencyView.swift in Sources */, + 4EB095602989CBFE0043A8A1 /* BalancesSectionView.swift in Sources */, + 4EEC157329F8242800D46A03 /* QRGeneratorView.swift in Sources */, 4EB095222989CBCB0043A8A1 /* Transaction.swift in Sources */, - 4EB0955D2989CBFE0043A8A1 /* CurrenciesListView.swift in Sources */, + 4E9320432A14F6EA00A87B0E /* WalletColors.swift in Sources */, + 4EB0955D2989CBFE0043A8A1 /* BalancesListView.swift in Sources */, 4EB095532989CBFE0043A8A1 /* PaymentAcceptView.swift in Sources */, 4EB095212989CBCB0043A8A1 /* WalletBackendError.swift in Sources */, - 4EB0955E2989CBFE0043A8A1 /* PendingRow.swift in Sources */, + 4EB0955E2989CBFE0043A8A1 /* PendingRowView.swift in Sources */, 4EB0955B2989CBFE0043A8A1 /* BalancesModel.swift in Sources */, 4EB095632989CBFE0043A8A1 /* WithdrawAcceptView.swift in Sources */, 4EB0956D2989CBFE0043A8A1 /* LoadingView.swift in Sources */, + 4E50B3502A1BEE8000F9F01C /* ManualWithdraw.swift in Sources */, + 4E87C8732A31CB7F001C6406 /* TransactionsEmptyView.swift in Sources */, + 4E87C8752A34B411001C6406 /* UncompletedRowView.swift in Sources */, + 4E40E0BE29F25ABB00B85369 /* SendAmount.swift in Sources */, + 4E8E25332A1CD39700A27BFA /* EqualIconWidthDomain.swift in Sources */, 4EB095542989CBFE0043A8A1 /* PaymentURIModel.swift in Sources */, 4EB0954F2989CBFE0043A8A1 /* SettingsView.swift in Sources */, 4EB095552989CBFE0043A8A1 /* PaymentURIView.swift in Sources */, 4EB095612989CBFE0043A8A1 /* WithdrawURIView.swift in Sources */, + 4EF840A72A0B85F400EE0D47 /* CopyShare.swift in Sources */, 4EB094ED298979620043A8A1 /* TalerWallet1App.swift in Sources */, 4EB095652989CBFE0043A8A1 /* WithdrawTOSView.swift in Sources */, + 4EEC157829F9032900D46A03 /* Sheet.swift in Sources */, + 4E6EDD852A3615BE0031D520 /* ManualDetails.swift in Sources */, 4EB0950B2989CB7C0043A8A1 /* View+dismissTop.swift in Sources */, 4EB095562989CBFE0043A8A1 /* TransactionsListView.swift in Sources */, 4EB0951F2989CBCB0043A8A1 /* WalletBackendRequest.swift in Sources */, - 4EB095572989CBFE0043A8A1 /* TransactionRow.swift in Sources */, + 4EAD117629F672FA008EDD0B /* KeyboardResponder.swift in Sources */, + 4EB095572989CBFE0043A8A1 /* TransactionRowView.swift in Sources */, 4EA1ABBE29A3833A008821EA /* PublicConstants.swift in Sources */, + 4EB3136129FEE79B007D68BC /* SendNow.swift in Sources */, 4EB0956B2989CBFE0043A8A1 /* TextFieldAlert.swift in Sources */, + 4EB431672A1E55C700C5690E /* ManualWithdrawDone.swift in Sources */, + 4E9320472A164BC700A87B0E /* ReceivePurpose.swift in Sources */, + 4E753A082A0B6A5F002D9328 /* ShareSheet.swift in Sources */, 4EB0956C2989CBFE0043A8A1 /* AmountView.swift in Sources */, + 4E363CBE2A23CB2100D7E98C /* AnyTransition+backslide.swift in Sources */, 4EB095592989CBFE0043A8A1 /* TransactionsModel.swift in Sources */, 4EB0955F2989CBFE0043A8A1 /* WalletEmptyView.swift in Sources */, + 4E16E12329F3BB99008B9C86 /* CurrencyFormatter.swift in Sources */, 4EB095192989CBB00043A8A1 /* WalletInitModel.swift in Sources */, 4EB095092989CB7C0043A8A1 /* TalerDater.swift in Sources */, + 4E363CC22A2621C200D7E98C /* LocalizedAlertError.swift in Sources */, 4EB0950E2989CB9A0043A8A1 /* quickjs.swift in Sources */, - 4EB095152989CBB00043A8A1 /* ExchangeTestModel.swift in Sources */, + 4E53A33729F50B7B00830EC2 /* CurrencyField.swift in Sources */, + 4EB095152989CBB00043A8A1 /* SettingsModel.swift in Sources */, 4EB095692989CBFE0043A8A1 /* ErrorView.swift in Sources */, 4EB0956E2989CBFE0043A8A1 /* PendingModel.swift in Sources */, 4EB095522989CBFE0043A8A1 /* ExchangeListView.swift in Sources */, 4EB095642989CBFE0043A8A1 /* WithdrawProgressView.swift in Sources */, - 4EB095582989CBFE0043A8A1 /* TransactionDetail.swift in Sources */, + 4EEC157A29F9427F00D46A03 /* QRSheet.swift in Sources */, + 4E6EDD872A363D8D0031D520 /* ListStyle.swift in Sources */, + 4EB095582989CBFE0043A8A1 /* TransactionDetailView.swift in Sources */, 4EB095202989CBCB0043A8A1 /* WalletCore.swift in Sources */, 4EB095672989CBFE0043A8A1 /* LaunchAnimationView.swift in Sources */, 4EB095662989CBFE0043A8A1 /* SideBarView.swift in Sources */, @@ -614,10 +752,18 @@ 4EB095702989CBFE0043A8A1 /* PendingOpsListView.swift in Sources */, 4EB095162989CBB00043A8A1 /* WalletModel.swift in Sources */, 4EB0955A2989CBFE0043A8A1 /* URLSheet.swift in Sources */, - 4EB095622989CBFE0043A8A1 /* WithdrawURIModel.swift in Sources */, + 4ED2F94B2A278F5100453B40 /* ThreeAmounts.swift in Sources */, + 4EB095622989CBFE0043A8A1 /* WithdrawModel.swift in Sources */, + 4EC90C782A1B528B0071DC58 /* ExchangeSectionView.swift in Sources */, + 4E7940DE29FC307C00A9AEA1 /* SendPurpose.swift in Sources */, + 4ECB62802A0BA6DF004ABBB7 /* Peer2peerModel.swift in Sources */, 4EB0950A2989CB7C0043A8A1 /* TalerStrings.swift in Sources */, + 4EA551252A2C923600FEC9A8 /* CurrencyInputView.swift in Sources */, + 4E363CBC2A237E0900D7E98C /* URL+iban.swift in Sources */, + 4E9320452A1645B600A87B0E /* RequestPayment.swift in Sources */, 4EB095502989CBFE0043A8A1 /* SettingsItem.swift in Sources */, - 4EB0955C2989CBFE0043A8A1 /* BalanceRow.swift in Sources */, + 4EB0955C2989CBFE0043A8A1 /* BalanceRowView.swift in Sources */, + 4E753A062A0952F8002D9328 /* DebugViewC.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -645,12 +791,12 @@ /* Begin PBXTargetDependency section */ D14AFD3524D232B500C51073 /* PBXTargetDependency */ = { isa = PBXTargetDependency; - target = D14AFD1C24D232B300C51073 /* TalerWalletT */; + target = D14AFD1C24D232B300C51073 /* Taler_Wallet */; targetProxy = D14AFD3424D232B500C51073 /* PBXContainerItemProxy */; }; D14AFD4024D232B500C51073 /* PBXTargetDependency */ = { isa = PBXTargetDependency; - target = D14AFD1C24D232B300C51073 /* TalerWalletT */; + target = D14AFD1C24D232B300C51073 /* Taler_Wallet */; targetProxy = D14AFD3F24D232B500C51073 /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ @@ -776,16 +922,20 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = TalerWalletT.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; - DEVELOPMENT_TEAM = GUDDQ9428Y; + CODE_SIGN_ENTITLEMENTS = "GNU Taler.entitlements"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 6; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = GUDDQ9428Y; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "Taler Wallet"; + INFOPLIST_KEY_CFBundleDisplayName = "GNU Taler"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; + INFOPLIST_KEY_NSCameraUsageDescription = "Scan QR Codes"; INFOPLIST_KEY_NSHumanReadableCopyright = "© Taler-Systems.com"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; @@ -795,8 +945,10 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 0.9.3; - PRODUCT_BUNDLE_IDENTIFIER = "com.taler-systems.talerwallet15"; - PRODUCT_NAME = "$(TARGET_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "com.taler-systems.talerwallet-1"; + PRODUCT_NAME = "GNU Taler"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = iOS_Distribution_230606; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; @@ -812,16 +964,20 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = TalerWalletT.entitlements; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 2; - DEVELOPMENT_TEAM = GUDDQ9428Y; + CODE_SIGN_ENTITLEMENTS = "GNU Taler.entitlements"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 6; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = GUDDQ9428Y; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = "Taler Wallet"; + INFOPLIST_KEY_CFBundleDisplayName = "GNU Taler"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.finance"; + INFOPLIST_KEY_NSCameraUsageDescription = "Scan QR Codes"; INFOPLIST_KEY_NSHumanReadableCopyright = "© Taler-Systems.com"; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations = UIInterfaceOrientationPortrait; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; @@ -831,8 +987,10 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 0.9.3; - PRODUCT_BUNDLE_IDENTIFIER = "com.taler-systems.talerwallet15"; - PRODUCT_NAME = "$(TARGET_NAME)"; + PRODUCT_BUNDLE_IDENTIFIER = "com.taler-systems.talerwallet-1"; + PRODUCT_NAME = "GNU Taler"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = iOS_Distribution_230606; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; @@ -880,7 +1038,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 0.9.3; PRODUCT_BUNDLE_IDENTIFIER = com.taler.TalerTests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -902,7 +1060,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 0.9.3; PRODUCT_BUNDLE_IDENTIFIER = com.taler.TalerUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -925,7 +1083,7 @@ "@executable_path/Frameworks", "@loader_path/Frameworks", ); - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 0.9.3; PRODUCT_BUNDLE_IDENTIFIER = com.taler.TalerUITests; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_EMIT_LOC_STRINGS = NO; @@ -947,7 +1105,7 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - D14AFD4724D232B500C51073 /* Build configuration list for PBXNativeTarget "TalerWalletT" */ = { + D14AFD4724D232B500C51073 /* Build configuration list for PBXNativeTarget "Taler_Wallet" */ = { isa = XCConfigurationList; buildConfigurations = ( D14AFD4824D232B500C51073 /* Debug */, @@ -985,6 +1143,14 @@ minimumVersion = 0.1.0; }; }; + 4EEC157429F8ECBF00D46A03 /* XCRemoteSwiftPackageReference "CodeScanner" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/twostraws/CodeScanner"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.0.0; + }; + }; ABE97B1B286D82BF00580772 /* XCRemoteSwiftPackageReference "AnyCodable" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/Flight-School/AnyCodable"; @@ -1001,6 +1167,11 @@ package = 4EB094FB29897D280043A8A1 /* XCRemoteSwiftPackageReference "SymLog" */; productName = SymLog; }; + 4EEC157529F8ECBF00D46A03 /* CodeScanner */ = { + isa = XCSwiftPackageProductDependency; + package = 4EEC157429F8ECBF00D46A03 /* XCRemoteSwiftPackageReference "CodeScanner" */; + productName = CodeScanner; + }; ABC13AA22859962800D23185 /* taler-swift */ = { isa = XCSwiftPackageProductDependency; productName = "taler-swift"; diff --git a/TalerWallet1/Assets.xcassets/taler-logo-2023-red.imageset/Contents.json b/TalerWallet1/Assets.xcassets/taler-logo-2023-red.imageset/Contents.json new file mode 100644 index 0000000..50ee748 --- /dev/null +++ b/TalerWallet1/Assets.xcassets/taler-logo-2023-red.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "taler-logo-2023-red.svg", + "idiom" : "universal" + }, + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/TalerWallet1/Assets.xcassets/taler-logo-2023-red.imageset/taler-logo-2023-red.svg b/TalerWallet1/Assets.xcassets/taler-logo-2023-red.imageset/taler-logo-2023-red.svg new file mode 100644 index 0000000..3820d36 --- /dev/null +++ b/TalerWallet1/Assets.xcassets/taler-logo-2023-red.imageset/taler-logo-2023-red.svg @@ -0,0 +1,19 @@ +<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg"> +<g id="aa" style="fill:red;fill-rule:evenodd"> +<!-- 90% --> +<path d="M57.6 43.4 +c-25.5 4.3-44.9 28-44.9 56.5 0 31.5 23.9 57.2 53.3 57.2 +s53.3-25.6 53.3-57.2 +c0-15.4-5.7-29.3-14.9-39.6 +c1.6-1.9 6.3-4.8 6.4-4.6 10 11.6 16.1 27.2 16.1 44.2 0 36-27.3 65.3-60.9 65.3-33.6 0-60.9-29.3-60.9-65.3 +s27.3-65.3 60.9-65.3 +c1.7 0 5.7.3 5.5.4-4.3 2.3-9.7 5.4-13.9 8.5"/> +<!-- 40% --> +<path d="M60.8 149.8 +c-13.4-12-22-29.9-22-50 0-36 27.4-65.2 61.1-65.2 1.5 0 3 .1 4.5.2 +a67.6 67.6 0 0 0-13.4 8.6 +c-25.4 4.5-44.7 28.1-44.7 56.4 0 21.3 11 40 27.3 49.8 +a45.9 45.9 0 0 1-12.7.3z"/> +</g> +<use transform="translate(200,200) rotate(180)" href="#aa"/> +</svg>
\ No newline at end of file diff --git a/TalerWallet1/Backend/Transaction.swift b/TalerWallet1/Backend/Transaction.swift index 873733e..b49d560 100644 --- a/TalerWallet1/Backend/Transaction.swift +++ b/TalerWallet1/Backend/Transaction.swift @@ -1,17 +1,6 @@ /* - * 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/> + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md */ import Foundation import AnyCodable @@ -26,22 +15,124 @@ enum TransactionDecodingError: Error { case invalidStringValue } +enum TransactionMinorState: String, Codable { + // Placeholder until D37 is fully implemented + case unknown + case deposit + case kyc // KycRequired + case aml // AmlRequired + case mergeKycRequired = "merge-kyc" + case track + case pay + case rebindSession = "rebind-session" + case refresh + case pickup + case autoRefund = "auto-refund" + case user + case bank + case exchange + case claimProposal = "claim-proposal" + case checkRefund = "check-refund" + case createPurse = "create-purse" + case deletePurse = "delete-purse" + case ready + case merge + case repurchase + case bankRegisterReserve = "bank-register-reserve" + case bankConfirmTransfer = "bank-confirm-transfer" + case withdrawCoins = "withdraw-coins" + case exchangeWaitReserve = "exchange-wait-reserve" + case abortingBank = "aborting-bank" + case refused + case withdraw + case merchantOrderProposed = "merchant-order-proposed" + case proposed + case refundAvailable = "refund-available" + case acceptRefund = "accept-refund" + + case submitPayment = "submit-payment" +} + +enum TransactionMajorState: String, Codable { + // No state, only used when reporting transitions into the initial state + case none + case pending + case done + case aborting + case aborted + case suspended + case dialog + case suspendedAborting = "suspended-aborting" + case failed + case expired + // Only used for the notification, never in the transaction history + case deleted + // Placeholder until D37 is fully implemented + case unknown +} + +struct TransactionState: Codable, Hashable { + var major: TransactionMajorState + var minor: TransactionMinorState? +} + +struct TransactionTransition: Codable { // Notification + enum TransitionType: String, Codable { + case transition = "transaction-state-transition" + } + var type: TransitionType + var oldTxState: TransactionState + var newTxState: TransactionState + var transactionId: String +} + +enum TxAction: String, Codable { + case abort // pending,dialog,suspended -> aborting +// case revive // aborting -> pending ?? maybe post 1.0 + case fail // aborting -> failed + case delete // dialog,done,expired,aborted,failed -> () + case suspend // pending -> suspended; aborting -> ab_suspended + case resume // suspended -> pending; ab_suspended -> aborting +} + struct TransactionCommon: Decodable { - var type: String + enum TransactionType: String, Codable { + case withdrawal + case deposit + case payment + case refund + case refresh + case reward +// case tip + case peerPushDebit = "peer-push-debit" // send coins to peer, show QR + case scanPushCredit = "peer-push-credit" // scan QR, receive coins from peer + case peerPullCredit = "peer-pull-credit" // request payment from peer, show QR + case scanPullDebit = "peer-pull-debit" // scan QR, pay requested + } + var type: TransactionType + var txState: TransactionState var amountEffective: Amount var amountRaw: Amount var transactionId: String var timestamp: Timestamp - var extendedStatus: String // TODO: enum with some fixed values? - var pending: Bool - var frozen: Bool + var txActions: [TxAction] func fee() -> Amount { do { return try Amount.diff(amountRaw, amountEffective) - } catch { - return Amount(currency: amountRaw.currencyStr, integer: 0, fraction: 0) - } + } catch {} + do { + return try Amount.diff(amountEffective, amountRaw) + } catch {} + return Amount(currency: amountRaw.currencyStr, integer: 0, fraction: 0) + } + + func incoming() -> Bool { + return type == .withdrawal + || type == .refund + || type == .reward + || type == .peerPullCredit + || type == .scanPushCredit } } @@ -49,12 +140,11 @@ struct WithdrawalDetails: Decodable { enum WithdrawalType: String, Decodable { case manual = "manual-transfer" case bankIntegrated = "taler-bank-integration-api" - case peerPullCredit = "peer-pull-credit" - case peerPushCredit = "peer-push-credit" } var type: WithdrawalType /// The public key of the reserve. var reservePub: String + var reserveIsReady: Bool /// Details for manual withdrawals: /// The payto URIs that the exchange supports. @@ -79,11 +169,11 @@ struct WithdrawalTransaction { struct PaymentTransactionDetails: Decodable { var proposalId: String - var status: String // "paid" var totalRefundRaw: Amount var totalRefundEffective: Amount var refundPending: Amount? -// var refunds: [] + var refundQueryActive: Bool? + var refunds: [String]? // TODO: array type? var info: OrderShortInfo } @@ -97,7 +187,7 @@ struct RefundTransactionDetails: Decodable { var refundPending: Amount? /// The amount that couldn't be applied because refund permissions expired. var amountInvalid: Amount? - var info: OrderShortInfo + var info: OrderShortInfo? // TODO: is this still here? } struct RefundTransaction { @@ -105,19 +195,44 @@ struct RefundTransaction { var details: RefundTransactionDetails } -struct TipTransactionDetails: Decodable { - /// The exchange that the tip will be withdrawn from +struct RewardTransactionDetails: Decodable { + /// The exchange that the reward will be withdrawn from var exchangeBaseUrl: String } -struct TipTransaction { +struct RewardTransaction { var common: TransactionCommon - var details: TipTransactionDetails + var details: RewardTransactionDetails } +//struct TipTransactionDetails: Decodable { +// /// The exchange that the tip will be withdrawn from +// var exchangeBaseUrl: String +//} + +//struct TipTransaction { +// var common: TransactionCommon +// var details: TipTransactionDetails +//} + +enum RefreshReason: String, Decodable { + case manual + case payMerchant = "pay-merchant" + case payDeposit = "pay-deposit" + case payPeerPush = "pay-peer-push" + case payPeerPull = "pay-peer-pull" + case refund + case abortPay = "abort-pay" + case abortDeposit = "abort-deposit" + case recoup + case backupRestored = "backup-restored" + case scheduled +} struct RefreshTransactionDetails: Decodable { - /// The exchange that the coins are refreshed with. - var exchangeBaseUrl: String + var refreshReason: RefreshReason + var originatingTransactionId: String? + var refreshInputAmount: Amount + var refreshOutputAmount: Amount } struct RefreshTransaction { @@ -125,51 +240,126 @@ struct RefreshTransaction { var details: RefreshTransactionDetails } +struct P2pShortInfo: Codable { + var summary: String + var expiration: Timestamp +} + +struct P2PTransactionDetails: Codable { + var exchangeBaseUrl: String + var talerUri: String? // only if we initiated the transaction + var info: P2pShortInfo +} + +struct P2PTransaction { + var common: TransactionCommon + var details: P2PTransactionDetails +} + enum Transaction: Decodable, Hashable, Identifiable { case withdrawal (WithdrawalTransaction) case payment (PaymentTransaction) case refund (RefundTransaction) - case tip (TipTransaction) + case reward (RewardTransaction) +// case tip (TipTransaction) case refresh (RefreshTransaction) + case peer2peer (P2PTransaction) init(from decoder: Decoder) throws { - let common = try TransactionCommon.init(from: decoder) - - switch (common.type) { - case WITHDRAWAL: - let details = try WithdrawalTransactionDetails.init(from: decoder) - self = .withdrawal(WithdrawalTransaction(common: common, details: details)) - case PAYMENT: - let details = try PaymentTransactionDetails.init(from: decoder) - self = .payment(PaymentTransaction(common: common, details: details)) - case REFUND: - let details = try RefundTransactionDetails.init(from: decoder) - self = .refund(RefundTransaction(common: common, details: details)) - case TIP: - let details = try TipTransactionDetails.init(from: decoder) - self = .tip(TipTransaction(common: common, details: details)) - case REFRESH: - let details = try RefreshTransactionDetails.init(from: decoder) - self = .refresh(RefreshTransaction(common: common, details: details)) - default: - let context = DecodingError.Context( - codingPath: decoder.codingPath, - debugDescription: "Invalid transaction type") - throw DecodingError.typeMismatch(Transaction.self, context) + do { + let common = try TransactionCommon.init(from: decoder) + + switch (common.type) { + case .withdrawal: + let details = try WithdrawalTransactionDetails.init(from: decoder) + self = .withdrawal(WithdrawalTransaction(common: common, details: details)) + case .payment: + let details = try PaymentTransactionDetails.init(from: decoder) + self = .payment(PaymentTransaction(common: common, details: details)) + case .refund: + let details = try RefundTransactionDetails.init(from: decoder) + self = .refund(RefundTransaction(common: common, details: details)) + case .reward: + let details = try RewardTransactionDetails.init(from: decoder) + self = .reward(RewardTransaction(common: common, details: details)) +// case .tip: +// let details = try TipTransactionDetails.init(from: decoder) +// self = .tip(TipTransaction(common: common, details: details)) + case .refresh: + let details = try RefreshTransactionDetails.init(from: decoder) + self = .refresh(RefreshTransaction(common: common, details: details)) + case .peerPushDebit, .peerPullCredit, .scanPullDebit, .scanPushCredit: + let details = try P2PTransactionDetails.init(from: decoder) + self = .peer2peer(P2PTransaction(common: common, details: details)) + default: + let context = DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Invalid transaction type") + throw DecodingError.typeMismatch(Transaction.self, context) + } + return + } catch DecodingError.dataCorrupted(let context) { + print(context) + throw TransactionDecodingError.invalidStringValue + } catch DecodingError.keyNotFound(let key, let context) { + print("Key '\(key)' not found:", context.debugDescription) + print("codingPath:", context.codingPath) + throw TransactionDecodingError.invalidStringValue + } catch DecodingError.valueNotFound(let value, let context) { + print("Value '\(value)' not found:", context.debugDescription) + print("codingPath:", context.codingPath) + throw TransactionDecodingError.invalidStringValue + } catch DecodingError.typeMismatch(let type, let context) { + print("Type '\(type)' mismatch:", context.debugDescription) + print("codingPath:", context.codingPath) + throw TransactionDecodingError.invalidStringValue + } catch { // TODO: native logging + print("error: ", error) + throw error } } - var id: String { common().transactionId } + var id: String { common.transactionId } static func == (lhs: Transaction, rhs: Transaction) -> Bool { return lhs.id == rhs.id } func hash(into hasher: inout Hasher) { - id.hash(into: &hasher) + hasher.combine(id) + hasher.combine(common.txState) // let SwiftUI redraw if txState changes } - func common() -> TransactionCommon { + var isWithdrawal : Bool { common.type == .withdrawal } + var isDeposit : Bool { common.type == .deposit } + var isPayment : Bool { common.type == .payment } + var isRefund : Bool { common.type == .refund } + var isRefresh : Bool { common.type == .refresh } + var isReward : Bool { common.type == .reward } +// var isTipPayment : Bool { common.type == .tip } + var isSendCoins : Bool { common.type == .peerPushDebit } + var isRcvCoins : Bool { common.type == .scanPushCredit } + var isSendInvoice: Bool { common.type == .peerPullCredit } + var isPayInvoice : Bool { common.type == .scanPullDebit } + + var isP2pOutgoing: Bool { isSendCoins || isPayInvoice} + var isP2pIncoming: Bool { isSendInvoice || isRcvCoins} + + var isDone : Bool { common.txState.major == .done } + var isPending : Bool { common.txState.major == .pending } + var isAborting : Bool { common.txState.major == .aborting } + var isAborted : Bool { common.txState.major == .aborted } + var isFailed : Bool { common.txState.major == .failed } + var isExpired : Bool { common.txState.major == .expired } + var isSpecial : Bool { isPending || isAborting || isAborted || isFailed || isExpired } + + var isAbortable : Bool { common.txActions.contains(.abort) } + var isFailable : Bool { common.txActions.contains(.fail) } + var isDeleteable : Bool { common.txActions.contains(.delete) } + var isResumable : Bool { common.txActions.contains(.resume) } + var isSuspendable: Bool { common.txActions.contains(.suspend) } + + var common: TransactionCommon { switch self { case .withdrawal(let withdrawalTransaction): return withdrawalTransaction.common @@ -177,10 +367,14 @@ enum Transaction: Decodable, Hashable, Identifiable { return paymentTransaction.common case .refund(let refundTransaction): return refundTransaction.common - case .tip(let tipTransaction): - return tipTransaction.common + case .reward(let rewardTransaction): + return rewardTransaction.common +// case .tip(let tipTransaction): +// return tipTransaction.common case .refresh(let refreshTransaction): return refreshTransaction.common + case .peer2peer(let p2pTransaction): + return p2pTransaction.common } } @@ -190,46 +384,23 @@ enum Transaction: Decodable, Hashable, Identifiable { case .withdrawal(let withdrawalTransaction): result[EXCHANGEBASEURL] = withdrawalTransaction.details.exchangeBaseUrl case .payment(let paymentTransaction): - result["status"] = paymentTransaction.details.status + result["summary"] = paymentTransaction.details.info.summary case .refund(let refundTransaction): - result["summary"] = refundTransaction.details.info.summary - case .tip(let tipTransaction): - result[EXCHANGEBASEURL] = tipTransaction.details.exchangeBaseUrl + if let info = refundTransaction.details.info { + result["summary"] = info.summary + } + case .reward(let rewardTransaction): + result[EXCHANGEBASEURL] = rewardTransaction.details.exchangeBaseUrl +// case .tip(let tipTransaction): +// result[EXCHANGEBASEURL] = tipTransaction.details.exchangeBaseUrl case .refresh(let refreshTransaction): - result[EXCHANGEBASEURL] = refreshTransaction.details.exchangeBaseUrl + result["reason"] = refreshTransaction.details.refreshReason.rawValue + case .peer2peer(let p2pTransaction): + result[EXCHANGEBASEURL] = p2pTransaction.details.exchangeBaseUrl + result["summary"] = p2pTransaction.details.info.summary + result[TALERURI] = p2pTransaction.details.talerUri ?? "" } - return result - } -} - -#if DEBUG -extension Transaction { // for PreViews - init(incoming: Bool, id: String, time: Timestamp) { - let effective = incoming ? "Taler:4.8" : "Taler:5.2" - let common = TransactionCommon(type: incoming ? WITHDRAWAL : PAYMENT, - amountEffective: try! Amount(fromString: effective), - amountRaw: try! Amount(fromString: "Taler:5"), - transactionId: id, timestamp: time, - extendedStatus: "done", pending: false, frozen: false) - if incoming { - let withdrawalDetails = WithdrawalDetails(type: WithdrawalDetails.WithdrawalType.bankIntegrated, - reservePub: "Public Key of the Exchange", - confirmed: true) - let wDetails = WithdrawalTransactionDetails(exchangeBaseUrl: "Exchange.Demo.Taler.net", - withdrawalDetails: withdrawalDetails) - self = .withdrawal(WithdrawalTransaction(common: common, details: wDetails)) - } else { - let merchant = Merchant(name: "some random shop") - let info = OrderShortInfo(orderId: "some order ID", - merchant: merchant, summary: "some product summary", products: []) - let pDetails = PaymentTransactionDetails(proposalId: "some proposal ID", - status: "paid", - totalRefundRaw: try! Amount(fromString: "Taler:3.2"), - totalRefundEffective: try! Amount(fromString: "Taler:3"), - info: info) - self = .payment(PaymentTransaction(common: common, details: pDetails)) - } + return result } } -#endif diff --git a/TalerWallet1/Backend/WalletBackendError.swift b/TalerWallet1/Backend/WalletBackendError.swift index cb0bfeb..802d027 100644 --- a/TalerWallet1/Backend/WalletBackendError.swift +++ b/TalerWallet1/Backend/WalletBackendError.swift @@ -1,17 +1,6 @@ /* - * 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/> + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md */ import Foundation @@ -25,7 +14,7 @@ enum WalletBackendError: Error { } /// Information supplied by the backend describing an error. -struct WalletBackendResponseError: Decodable { +struct WalletBackendResponseError: Codable { /// Numeric error code defined defined in the GANA gnu-taler-error-codes registry. var talerErrorCode: Int diff --git a/TalerWallet1/Backend/WalletBackendRequest.swift b/TalerWallet1/Backend/WalletBackendRequest.swift index ad48879..9bf8d80 100644 --- a/TalerWallet1/Backend/WalletBackendRequest.swift +++ b/TalerWallet1/Backend/WalletBackendRequest.swift @@ -1,17 +1,6 @@ /* - * 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/> + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md */ import Foundation import AnyCodable @@ -34,8 +23,37 @@ protocol WalletBackendFormattedRequest { func args() -> Args } // MARK: - - - +/// The scope of a currency: either global or a dedicated exchange +struct ScopeInfo: Codable, Hashable { + enum ScopeInfoType: String, Codable { + case global + case exchange + } + var type: ScopeInfoType + var exchangeBaseUrl: String? // only for "exchange" + var currency: String + + public static func == (lhs: ScopeInfo, rhs: ScopeInfo) -> Bool { + if let lhsBaseURL = lhs.exchangeBaseUrl { + if let rhsBaseURL = rhs.exchangeBaseUrl { + if lhsBaseURL != rhsBaseURL { return false } // different exchanges + // else fall thru and check type & currency + } else { return false } // left but not right + } else if rhs.exchangeBaseUrl != nil { + return false // right but not left + } + return lhs.type == rhs.type && + lhs.currency == rhs.currency + } + public func hash(into hasher: inout Hasher) { + hasher.combine(type) + if let baseURL = exchangeBaseUrl { + hasher.combine(baseURL) + } + hasher.combine(currency) + } +} +// MARK: - /// A billing or mailing location. struct Location: Codable { var country: String? @@ -64,14 +82,15 @@ struct Tax: Codable { } /// A product being purchased from a merchant. +/// https://docs.taler.net/core/api-merchant.html#the-contract-terms struct Product: Codable { var product_id: String? var description: String - // description_i18n? - var quantity: Int - var unit: String +// var description_i18n: ? + var quantity: Int? + var unit: String? var price: Amount? - var image: String // URL to a product image + var image: String? // URL to a product image var taxes: [Tax]? var delivery_date: Timestamp? } @@ -81,33 +100,14 @@ struct OrderShortInfo: Codable { var orderId: String var merchant: Merchant var summary: String - // summary_i18n? +// var summary_i18n: ? var products: [Product] var fulfillmentUrl: String? var fulfillmentMessage: String? - // fulfillmentMessage_i18n? +// var fulfillmentMessage_i18n: ? + var contractTermsHash: String? } - - -/// 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) - } -} - +// MARK: - /// A request to process a refund. struct WalletBackendApplyRefundRequest: WalletBackendFormattedRequest { var talerRefundUri: String @@ -154,55 +154,6 @@ struct WalletBackendForceUpdateRequest: WalletBackendFormattedRequest { } } - - - -/// 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 @@ -264,51 +215,40 @@ struct WalletBackendConfirmPayRequest: WalletBackendFormattedRequest { } } -/// 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) - } +// MARK: - +/// The result from PrepareReward +struct PrepareRewardResponse: Decodable { + var walletRewardId: String + var accepted: Bool + var rewardAmountRaw: Amount + var rewardAmountEffective: Amount + var exchangeBaseUrl: String + var expirationTimestamp: Timestamp } +/// A request to prepare a reward. +struct PrepareRewardRequest: WalletBackendFormattedRequest { + typealias Response = PrepareRewardResponse + func operation() -> String { return "prepareReward" } + func args() -> Args { return Args(talerRewardUri: talerRewardUri) } -/// A request to accept a tip. -struct WalletBackendAcceptTipRequest: WalletBackendFormattedRequest { - var walletTipId: String - + var talerRewardUri: String struct Args: Encodable { - var walletTipId: String + var talerRewardUri: String } - +} +// MARK: - +/// A request to accept a reward. +struct AcceptRewardRequest: WalletBackendFormattedRequest { struct Response: Decodable {} - - func operation() -> String { - return "acceptTip" - } - - func args() -> Args { - return Args(walletTipId: walletTipId) + func operation() -> String { return "acceptReward" } + func args() -> Args { return Args(walletRewardId: walletRewardId) } + + var walletRewardId: String + struct Args: Encodable { + var walletRewardId: String } } - +// MARK: - /// A request to abort a failed payment. struct WalletBackendAbortFailedPaymentRequest: WalletBackendFormattedRequest { var proposalId: String @@ -327,8 +267,7 @@ struct WalletBackendAbortFailedPaymentRequest: WalletBackendFormattedRequest { return Args(proposalId: proposalId) } } - - +// MARK: - struct IntegrationTestArgs: Codable { var exchangeBaseUrl: String var bankBaseUrl: String diff --git a/TalerWallet1/Backend/WalletCore.swift b/TalerWallet1/Backend/WalletCore.swift index be7450d..c7205a7 100644 --- a/TalerWallet1/Backend/WalletCore.swift +++ b/TalerWallet1/Backend/WalletCore.swift @@ -1,17 +1,6 @@ /* - * 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/> + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md */ import SwiftUI // FOUNDATION has no AppStorage import AnyCodable @@ -24,13 +13,18 @@ protocol WalletBackendDelegate { func walletBackendReceivedUnknownMessage(_ walletCore: WalletCore, message: String) } +// MARK: - /// An interface to the wallet backend. class WalletCore: QuickjsMessageHandler { + public static let shared = try! WalletCore() // will (and should) crash on failure private let symLog = SymLogC() + private var queue: DispatchQueue + private var semaphore: DispatchSemaphore + private var quickjs: Quickjs private var requestsMade: UInt // counter for array of completion closures - private var completions: [UInt : (UInt, Data?, WalletBackendResponseError?) -> Void] = [:] + private var completions: [UInt : (Date, (UInt, Date, Data?, WalletBackendResponseError?) -> Void)] = [:] var delegate: WalletBackendDelegate? var versionInfo: VersionInfo? // shown in SettingsView @@ -41,7 +35,7 @@ class WalletCore: QuickjsMessageHandler { let id: UInt let args: AnyEncodable } - + private struct FullResponse: Decodable { let type: String let operation: String @@ -58,6 +52,21 @@ class WalletCore: QuickjsMessageHandler { var lastError: FullError? + struct ResponseOrNotification: Decodable { + let type: String + let operation: String? + let id: UInt? + let result: AnyCodable? + let error: AnyCodable? // should be WalletBackendResponseError? + let payload: AnyCodable? + } + + struct Payload: Decodable { + let type: String + let id: String? + let reservePub: String? + } + deinit { symLog.log() // TODO: send shutdown message to talerWalletInstance @@ -65,95 +74,157 @@ class WalletCore: QuickjsMessageHandler { } init() throws { + symLog.log("alloc.init wallet-core") requestsMade = 0 + queue = DispatchQueue(label: "net.taler.myQueue", attributes: .concurrent) + semaphore = DispatchSemaphore(value: 1) quickjs = Quickjs() quickjs.messageHandler = self + symLog.log("wallet-core done") } } // 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 { + private func handleError(_ decoded: ResponseOrNotification) throws { + guard let requestId = decoded.id else { + symLog.log(decoded) // TODO: .error + throw WalletBackendError.deserializationError + } + guard let (timeSent, completion) = completions[requestId] else { + symLog.log("requestId \(requestId) not in list") // TODO: .error + throw WalletBackendError.deserializationError + } + completions[requestId] = nil + if let walletError = decoded.error { // wallet-core sent an error message 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()) + let jsonData = try JSONEncoder().encode(walletError) + } catch { // JSON encoding of response.result failed / should never happen + symLog.log(walletError) // TODO: .error + completion(requestId, timeSent, nil, WalletCore.parseFailureError()) } + + // TODO: decode jsonData to WalletBackendResponseError - or HTTPError +// completion(requestId, timeSent, nil, walletError) + completion(requestId, timeSent, nil, WalletCore.parseFailureError()) + } else { // JSON decoding of error message failed + completion(requestId, timeSent, nil, WalletCore.parseFailureError()) + } + } + private func handleResponse(_ decoded: ResponseOrNotification) throws { + guard let requestId = decoded.id else { + symLog.log(decoded) // TODO: .error + throw WalletBackendError.deserializationError + } + guard let (timeSent, completion) = completions[requestId] else { + symLog.log("requestId \(requestId) not in list") // TODO: .error + throw WalletBackendError.deserializationError + } + completions[requestId] = nil + guard let result = decoded.result else { + symLog.log("requestId \(requestId) got no result") // TODO: .error + throw WalletBackendError.deserializationError + } + do { + let jsonData = try JSONEncoder().encode(result) + symLog.log(result) // TODO: .info + completion(requestId, timeSent, jsonData, nil) + } catch { // JSON encoding of response.result failed / should never happen + symLog.log(result) // TODO: .error + completion(requestId, timeSent, nil, WalletCore.parseResponseError()) + } + } + + @MainActor + private func postNotificationM(_ aName: NSNotification.Name, + object anObject: Any? = nil, + userInfo: [AnyHashable: Any]? = nil) async { + symLog.log(aName.rawValue) + NotificationCenter.default.post(name: aName, object: anObject, userInfo: userInfo) + } + private func postNotification(_ aName: NSNotification.Name, + object anObject: Any? = nil, + userInfo: [AnyHashable: Any]? = nil) { + Task { + await postNotificationM(aName, object: anObject, userInfo: userInfo) + } + if let userInfo { symLog.log(userInfo) } else { symLog.log(aName) } + } + + private func handlePendingProcessed(_ payload: Payload) throws { + guard let id = payload.id else { throw WalletBackendError.deserializationError } + let pendingOp = Notification.Name.PendingOperationProcessed.rawValue + if id.hasPrefix("exchange-update:") { + // Bla Bla Bla + } else if id.hasPrefix("refresh:") { + // Bla Bla Bla + } else if id.hasPrefix("purchase:") { + // TODO: handle purchase +// symLog.log("\(pendingOp): \(id)") + } else if id.hasPrefix("withdraw:") { + // TODO: handle withdraw +// symLog.log("\(pendingOp): \(id)") } 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()) - } + // TODO: handle other pending-operation-processed +print("\n❗️ \(pendingOp): \(id)\n") // this is a new pendingOp I haven't seen before + print("\n") + } + } + private func handleStateTransition(_ jsonData: Data) throws { + do { + let decoded = try JSONDecoder().decode(TransactionTransition.self, from: jsonData) + postNotification(.TransactionStateTransition, + userInfo: [TRANSACTIONTRANSITION: decoded]) + } catch { // rethrows + symLog.log(jsonData) // TODO: .error + throw WalletBackendError.deserializationError } } - private func handleNotification(dict responseDict: [String : Any]) throws { + private func handleNotification(_ anyCodable: AnyCodable?) throws { + guard let anyPayload = anyCodable else { throw WalletBackendError.deserializationError } 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 { - // automatically fetch balances after receiving these notifications - 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 + let jsonData = try JSONEncoder().encode(anyPayload) + let payload = try JSONDecoder().decode(Payload.self, from: jsonData) + + switch payload.type { + case Notification.Name.TransactionStateTransition.rawValue: + try handleStateTransition(jsonData) + case Notification.Name.PendingOperationProcessed.rawValue: + try handlePendingProcessed(payload) + case Notification.Name.ExchangeAdded.rawValue: + postNotification(.ExchangeAdded) + case Notification.Name.ReserveNotYetFound.rawValue: + if let reservePub = payload.reservePub { + let userInfo = ["reservePub" : reservePub] +// postNotification(.ReserveNotYetFound, userInfo: userInfo) // TODO: remind User to confirm withdrawal + } // else { throw WalletBackendError.deserializationError } shouldn't happen, but if it does just ignore it + + case Notification.Name.ProposalAccepted.rawValue: // "proposal-accepted": + symLog.log(anyPayload) + postNotification(.ProposalAccepted, userInfo: nil) + case Notification.Name.ProposalDownloaded.rawValue: // "proposal-downloaded": + symLog.log(anyPayload) + postNotification(.ProposalDownloaded, userInfo: nil) + case "reserve-registered-with-bank": - symLog.log(payload) + symLog.log(anyPayload) + + // TODO: remove these once wallet-core doesn't send them anymore + case "withdraw-group-finished", + "pay-operation-success", + "withdrawal-group-bank-confirmed", // replaced by transaction-state-transition + "withdrawal-group-reserve-ready", + "coin-withdrawn", // totally useless since wallet-core handles coins in bulk + "waiting-for-retry", // Bla Bla Bla + "refresh-started", "refresh-melted", + "refresh-revealed", "refresh-unwarranted": break default: - symLog.log(payload) +print("\n❗️ ", anyPayload, "\n") // this is a new notification I haven't seen before break } } catch let error { - symLog.log("Error \(error) parsing notification: \(responseDict)") // TODO: .error + symLog.log("Error \(error) parsing notification: \(anyPayload)") // TODO: .error // TODO: if DevMode then should log into file for user } } @@ -162,7 +233,7 @@ extension WalletCore { func handleMessage(message: String) { do { var asyncDelay = 0 - if let delay = developDelay { + if let delay: Bool = developDelay { // Settings: 2 seconds delay if delay { asyncDelay = 2 } @@ -173,42 +244,61 @@ extension WalletCore { 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 { + guard let messageData = message.data(using: .utf8) else { + throw WalletBackendError.deserializationError + } + let decoded = try JSONDecoder().decode(ResponseOrNotification.self, from: messageData) + switch decoded.type { case "error": - try handleResponse(dict: responseDict, data: messageData, isError: true) + symLog.log(decoded) // TODO: .error + try handleError(decoded) case "response": - try handleResponse(dict: responseDict, data: messageData) + try handleResponse(decoded) case "notification": - try handleNotification(dict: responseDict) + try handleNotification(decoded.payload) case "tunnelHttp": // TODO: Handle tunnelHttp - break + symLog.log("Can't handle tunnelHttp: \(decoded)") // TODO: .error + throw WalletBackendError.deserializationError default: - symLog.log("Unknown response type: \(responseDict)") // TODO: .error + symLog.log("Unknown response type: \(decoded)") // TODO: .error throw WalletBackendError.deserializationError } - } catch { + } catch DecodingError.dataCorrupted(let context) { + print(context) + } catch DecodingError.keyNotFound(let key, let context) { + print("Key '\(key)' not found:", context.debugDescription) + print("codingPath:", context.codingPath) + } catch DecodingError.valueNotFound(let value, let context) { + print("Value '\(value)' not found:", context.debugDescription) + print("codingPath:", context.codingPath) + } catch DecodingError.typeMismatch(let type, let context) { + print("Type '\(type)' mismatch:", context.debugDescription) + print("codingPath:", context.codingPath) + } catch { // TODO: ? delegate?.walletBackendReceivedUnknownMessage(self, message: message) } } - private func sendRequest(request: WalletBackendRequest, completionHandler: @escaping (UInt, Data?, WalletBackendResponseError?) -> Void) { + private func sendRequest(request: WalletBackendRequest, completionHandler: @escaping (UInt, Date, 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()); + queue.async { + self.semaphore.wait() // guard access to requestsMade + let requestId = self.requestsMade + let now = Date.now + do { + let full = FullRequest(operation: request.operation, id: requestId, args: request.args) + // symLog.log(full) + let encoded = try JSONEncoder().encode(full) + guard let jsonString = String(data: encoded, encoding: .utf8) else { throw WalletBackendError.serializationError } + self.completions[requestId] = (now, completionHandler) + self.requestsMade += 1 + self.semaphore.signal() // free requestsMade + self.symLog.log(jsonString) + self.quickjs.sendMessage(message: jsonString) + } catch { // call completion + self.semaphore.signal() + completionHandler(requestId, now, nil, WalletCore.serializeRequestError()); + } } } } @@ -219,27 +309,44 @@ extension WalletCore { let reqData = WalletBackendRequest(operation: request.operation(), args: AnyEncodable(request.args())) return try await withCheckedThrowingContinuation { continuation in - sendRequest(request: reqData) { id, result, error in + sendRequest(request: reqData) { requestId, timeSent, result, error in + let timeUsed = Date.now - timeSent + let millisecs = timeUsed.milliseconds +print("Request \(requestId) took \(millisecs) ms") + var err: Error? = nil if let json = result { do { let decoded = try JSONDecoder().decode(T.Response.self, from: json) - continuation.resume(returning: (decoded, id)) - } catch { + continuation.resume(returning: (decoded, requestId)) + return + } catch DecodingError.dataCorrupted(let context) { + print(context) + } catch DecodingError.keyNotFound(let key, let context) { + print("Key '\(key)' not found:", context.debugDescription) + print("codingPath:", context.codingPath) + } catch DecodingError.valueNotFound(let value, let context) { + print("Value '\(value)' not found:", context.debugDescription) + print("codingPath:", context.codingPath) + } catch DecodingError.typeMismatch(let type, let context) { + print("Type '\(type)' mismatch:", context.debugDescription) + print("codingPath:", context.codingPath) + } catch { // rethrows 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) + err = error // this will be thrown } } else { if let error = error { - self.lastError = FullError(type: "error", operation: request.operation(), id: id, error: error) + self.lastError = FullError(type: "error", operation: request.operation(), id: requestId, error: error) } else { self.lastError = nil } - continuation.resume(throwing: WalletBackendError.walletCoreError) + err = WalletBackendError.walletCoreError } + continuation.resume(throwing: err ?? TransactionDecodingError.invalidStringValue) } } } diff --git a/TalerWallet1/Controllers/Controller.swift b/TalerWallet1/Controllers/Controller.swift index 8e10ec6..9a3db72 100644 --- a/TalerWallet1/Controllers/Controller.swift +++ b/TalerWallet1/Controllers/Controller.swift @@ -1,19 +1,9 @@ /* - * 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/> + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md */ import Foundation +import SwiftUI import SymLog enum BackendState { @@ -30,52 +20,35 @@ enum UrlCommand { case pay } +// MARK: - class Controller: ObservableObject { - public static var shared = Controller() + public static let 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 + @Published var backendState: BackendState = .none // only used for launch animation 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 { + @MainActor func initWalletCoreM() + async throws { // M for MainActor 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 + let walletInitModel = WalletInitModel() + let versionInfo = try await walletInitModel.initWalletT() + WalletCore.shared.versionInfo = versionInfo + backendState = .ready // dismiss the launch animation + } catch { // rethrows + symLog.log(error.localizedDescription) + backendState = .error // TODO: ❗️Yikes app cannot continue throw error } } else { - symLog.log("Yikes\(logSymbol(-1)) wallet-core already initialized") // TODO: .warning + symLog.log("Yikes❗️ wallet-core already initialized") // TODO: .warning } } } @@ -112,7 +85,7 @@ extension Controller { func talerScheme(_ url:URL,_ uncrypted: Bool = false) -> UrlCommand { guard let command = url.host else {return UrlCommand.unknown} if uncrypted { - print("uncrypted") + symLog.log("uncrypted: taler://\(command)") // TODO: uncrypted } switch command { diff --git a/TalerWallet1/Controllers/DebugViewC.swift b/TalerWallet1/Controllers/DebugViewC.swift new file mode 100644 index 0000000..828ba08 --- /dev/null +++ b/TalerWallet1/Controllers/DebugViewC.swift @@ -0,0 +1,163 @@ +/* MIT License + * Copyright (c) 2023 Marc Stibane + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import SwiftUI +import SymLog + +// Numbering Scheme for Views +// MARK: - Main View +public let VIEW_EMPTY = 10 // 10 WalletEmptyView +public let VIEW_BALANCES = VIEW_EMPTY + 1 // 11 BalancesListView +public let VIEW_SETTINGS = VIEW_BALANCES + 1 // 12 SettingsView +public let VIEW_PENDING = VIEW_SETTINGS + 1 // 13 PendingOpsListView +public let VIEW_EXCHANGES = VIEW_PENDING + 1 // 14 ExchangeListView + +// MARK: Transactions +public let VIEW_TRANSACTIONLIST = VIEW_EMPTY + 10 // 20 TransactionsListView +public let VIEW_TRANSACTIONDETAIL = VIEW_TRANSACTIONLIST + 1 // 21 TransactionDetail + + + +// MARK: - Manual Withdrawal (from ExchangeList) +// receive coins from bank ==> shows IBAN + Purpose for manual wire transfer +public let VIEW_WITHDRAWAL = VIEW_TRANSACTIONLIST + 10 // 30 WithdrawAmount +public let VIEW_WITHDRAW_TOS = VIEW_WITHDRAWAL + 1 // 31 WithdrawTOSView +public let VIEW_WITHDRAW_ACCEPT = VIEW_WITHDRAW_TOS + 1 // 32 + +// MARK: Manual Deposit (from ExchangeList) +// send coins back to bank account ==> orders exchange to make the wire transfer +public let VIEW_DEPOSIT = VIEW_WITHDRAWAL + 10 // 40 Deposit Coins +//public let VIEW_DEPOSIT_TOS // 41 - user already accepted the ToS at withdrawal, invoice or receive +public let VIEW_DEPOSIT_ACCEPT = VIEW_DEPOSIT + 2 // 42 + +// MARK: P2P Invoice (from Balances) +// pull credit from another wallet ==> shows QR code to be scanned / link to be sent by mail or messenger +public let VIEW_INVOICE_P2P = VIEW_DEPOSIT + 10 // 50 Invoice Amount +public let VIEW_INVOICE_TOS = VIEW_INVOICE_P2P + 1 // 51 Invoice ToS +public let VIEW_INVOICE_PURPOSE = VIEW_INVOICE_TOS + 1 // 52 Invoice Purpose + +// MARK: P2P Send Coins (from Balances) +// push debit to another wallet ==> shows QR code to be scanned / link to be sent by mail or messenger +public let VIEW_SEND_P2P = VIEW_INVOICE_P2P + 10 // 60 Send Coins +//public let VIEW_SEND_TOS // 61 - user already accepted the ToS at withdrawal, invoice or receive +public let VIEW_SEND_PURPOSE = VIEW_SEND_P2P + 2 // 62 + + + +// MARK: - Bank-Integrated Withdrawal +// openURL (Link or scan QR) ==> obtains coins from bank +public let SHEET_WITHDRAWAL = VIEW_WITHDRAWAL + 100 // 130 WithdrawURIView +public let SHEET_WITHDRAW_TOS = SHEET_WITHDRAWAL + 1 // 131 WithdrawTOSView +public let SHEET_WITHDRAW_ACCEPT = SHEET_WITHDRAW_TOS + 1 // 132 WithdrawAcceptView +public let SHEET_WITHDRAW_CONFIRM = SHEET_WITHDRAW_ACCEPT + 1 // 133 waiting for bank confirmation + +// MARK: Merchant Payment +// openURL (Link, NFC or scan QR) ==> pays merchant +public let SHEET_PAYMENT = SHEET_WITHDRAWAL + 10 // 140 Pay Merchant + +// MARK: Reward - Receive Coins (from merchant) +// openURL (Link, NFC or scan QR) ==> receive coins from merchant +public let SHEET_RCV_REWARD = SHEET_PAYMENT + 10 // 150 Receive Reward + +// MARK: P2P Pay Invoice +// p2p pull debit - openURL (Link or scan QR) +public let SHEET_PAY_P2P = SHEET_RCV_REWARD + 10 // 160 Pay P2P Invoice + +// MARK: P2P Receive Coins +// p2p push credit - openURL (Link or scan QR) +public let SHEET_RCV_P2P = SHEET_PAY_P2P + 10 // 170 Receive P2P Coins + +//public let SHEET_REFUND = + + +// MARK: - +struct DebugViewV: View { + private let symLog = SymLogV() + @EnvironmentObject private var debugViewC: DebugViewC + + var body: some View { +#if DEBUG + let _ = Self._printChanges() + let _ = symLog.vlog() // just to get the # to compare it with .onAppear & onDisappear +#endif + let viewIDString = debugViewC.viewID > 0 ? String(debugViewC.viewID) + : "" + HStack { + Spacer() + Spacer() + Text(viewIDString) + .font(.caption2) + .foregroundColor(.red) + Spacer() + } + .edgesIgnoringSafeArea(.top) + } +} +// MARK: - +class DebugViewC: ObservableObject { + private let symLog = SymLogC() // 0 to switch off viewID change logging + public static let shared = DebugViewC() + @AppStorage("developerMode") var developerMode: Bool = false + + @Published var viewID: Int = 0 + @Published var sheetID: Int = 0 + + @MainActor func setViewID(_ newID: Int) -> Void { + if developerMode { + if viewID == 0 { + symLog.log("switching ON, \(newID)") + viewID = newID // publish ON + } else if viewID != newID { + symLog.log("switching from \(viewID) to \(newID)") + viewID = newID // publish new viewID + } else { + symLog.log("\(newID) stays") + // don't set viewID to the same value, it would just trigger an unneccessary redraw + } + } else if viewID > 0 { + symLog.log("switching OFF, will not use \(newID)") + viewID = 0 // publish OFF + } else { + symLog.log("off, will not use \(newID)") + // don't set viewID from 0 to 0 again, it would just trigger an unneccessary redraw + } + } + + @MainActor func setSheetID(_ newID: Int) -> Void { + if developerMode { + if sheetID != newID { + symLog.log("switching from \(sheetID) to \(newID)") + sheetID = newID // publish new sheetID + } else { + symLog.log("\(newID) stays") + // don't set sheetID to the same value, it would just trigger an unneccessary redraw + } + } else if sheetID > 0 { + // might happen after switching DevMode off, if sheetID still has the old value of the last sheet + symLog.log("switching OFF, will not use \(newID)") + sheetID = 0 // publish OFF + } else { + symLog.log("off, will not use \(newID)") + // don't set sheetID from 0 to 0 again, it would just trigger an unneccessary redraw + } + } + +} diff --git a/TalerWallet1/Controllers/PublicConstants.swift b/TalerWallet1/Controllers/PublicConstants.swift new file mode 100644 index 0000000..386f6f6 --- /dev/null +++ b/TalerWallet1/Controllers/PublicConstants.swift @@ -0,0 +1,35 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +import Foundation + +public let DEMOBANK = "https://bAnK.dEmO.tAlEr.nEt" // should be weird to read, but still work +public let DEMOSHOP = "https://shop.demo.taler.net" +//public let DEMOEXCHANGE = "https://eXcHaNgE.dEmO.tAlEr.nEt" +public let DEMOEXCHANGE = "https://exchange.demo.taler.net" +public let DEMO_AGE_EXCHANGE = "https://exchange-age.taler.ar" +public let DEMO_EXP_EXCHANGE = "https://exchange-expensive.taler.ar" +public let DEMOCURRENCY = "KUDOS" +//public let LONGCURRENCY = "gold-pressed Latinum" // 20 characters, with dash and space +public let LONGCURRENCY = "GOLDLATINUM" // 11 characters, no dash, no space + +// MARK: - Keys used in JSON + +public let EXCHANGEBASEURL = "exchangeBaseUrl" +public let TALERURI = "talerUri" + +public let TRANSACTIONTRANSITION = "transactionTransition" + +extension Notification.Name { + static let TransactionStateTransition = Notification.Name(TransactionTransition.TransitionType.transition.rawValue) + static let ExchangeAdded = Notification.Name("exchange-added") + static let PendingOperationProcessed = Notification.Name("pending-operation-processed") + static let ReserveNotYetFound = Notification.Name("reserve-not-yet-found") +// static let WithdrawalGroupBankConfirmed = Notification.Name("withdrawal-group-bank-confirmed") +// static let WithdrawalGroupReserveReady = Notification.Name("withdrawal-group-reserve-ready") +// static let WithdrawGroupFinished = Notification.Name("withdraw-group-finished") +// static let PayOperationSuccess = Notification.Name("pay-operation-success") + static let ProposalAccepted = Notification.Name("proposal-accepted") + static let ProposalDownloaded = Notification.Name("proposal-downloaded") +} diff --git a/TalerWallet1/Controllers/TalerWallet1App.swift b/TalerWallet1/Controllers/TalerWallet1App.swift index 1eac830..98593ce 100644 --- a/TalerWallet1/Controllers/TalerWallet1App.swift +++ b/TalerWallet1/Controllers/TalerWallet1App.swift @@ -1,17 +1,12 @@ /* - * 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. + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +/** + * Main app entry point * - * 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/> + * @author Marc Stibane + * @author Jonathan Buchanan */ import BackgroundTasks import SwiftUI @@ -19,11 +14,15 @@ import SymLog @main struct TalerWallet1App: App { - private let symLog = SymLogV(0) + private let symLog = SymLogV() @Environment(\.scenePhase) private var phase + @StateObject private var viewState = ViewState.shared // popToRootView() + @State private var isActive = true + private let walletCore = WalletCore.shared // our main controller - @StateObject private var controller = Controller.shared + private let controller = Controller.shared + private let debugViewC = DebugViewC.shared func scheduleAppRefresh() { let request = BGAppRefreshTaskRequest(identifier: "net.taler.refresh") @@ -33,21 +32,41 @@ struct TalerWallet1App: App { var body: some Scene { WindowGroup { - symLog { ContentView() - .environmentObject(controller) - /// external events are taler:// or payto:// URLs passed to this app - /// we handle them in .onOpenURL in ContentView.swift - .handlesExternalEvents(preferring: ["*"], allowing: ["*"]) - .task { - symLog.log("task -> initWalletCore") - try! await controller.initWalletCore() // will (and should) crash on failure - symLog.log("task done") - } - } + MainView() + .environmentObject(debugViewC) // change viewID / sheetID + .environmentObject(viewState) // popToRoot + .environmentObject(controller) + /// external events are taler:// or payto:// URLs passed to this app + /// we handle them in .onOpenURL in MainView.swift + .handlesExternalEvents(preferring: ["*"], allowing: ["*"]) + .task { + symLog.log("task -> initWalletCore") + try! await controller.initWalletCoreM() // will (and should) crash on failure + symLog.log("task -> initWalletCore done") + } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification, object: nil)) { _ in + print("❗️App Did Become Active notification") + } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification, object: nil)) { _ in + print("❗️App Will Resign notification") + isActive = false + } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification, object: nil)) { _ in + print("❗️App Will Enter Foreground notification") + isActive = true + } + .onReceive(NotificationCenter.default.publisher(for: UIApplication.willTerminateNotification, object: nil)) { _ in + print("❗️App Will Terminate notification") + } + } .onChange(of: phase) { newPhase in switch newPhase { - case .background: scheduleAppRefresh() + case .active: + print("❗️ .onChange(of: phase) ==> newPhase: Active") + case .background: + print("❗️ .onChange(of: phase) ==> newPhase: Background)") + scheduleAppRefresh() default: break } } @@ -76,3 +95,15 @@ struct TalerWallet1App: App { } } + +final class ViewState : ObservableObject { + static let shared = ViewState() + @Published var rootViewId = UUID() + + public func popToRootView() -> Void { + let _ = symLog() + rootViewId = UUID() // setting a new ID will cause tableView popToRootView behaviour + } + + private init() { } +} diff --git a/TalerWallet1/Helper/AnyTransition+backslide.swift b/TalerWallet1/Helper/AnyTransition+backslide.swift new file mode 100644 index 0000000..3cd1d81 --- /dev/null +++ b/TalerWallet1/Helper/AnyTransition+backslide.swift @@ -0,0 +1,30 @@ +/* MIT License + * Copyright (c) 2021 noranraskin + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import SwiftUI + +extension AnyTransition { + static var backslide: AnyTransition { + AnyTransition.asymmetric( + insertion: .move(edge: .trailing), + removal: .move(edge: .leading)) + } +} diff --git a/TalerWallet1/Helper/CurrencyFormatter.swift b/TalerWallet1/Helper/CurrencyFormatter.swift new file mode 100644 index 0000000..42f391b --- /dev/null +++ b/TalerWallet1/Helper/CurrencyFormatter.swift @@ -0,0 +1,27 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +import Foundation +import taler_swift + +public class CurrencyFormatter: NumberFormatter { + public static var shared = CurrencyFormatter() + + private override convenience init() { + self.init(fractionDigits: 2) + } + + public init(fractionDigits: Int) { + super.init() + self.numberStyle = .decimal // currency could be changed by user + self.minimumFractionDigits = fractionDigits + self.maximumFractionDigits = fractionDigits + self.usesGroupingSeparator = true + self.locale = Locale.current + } + + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/TalerWallet1/Helper/EqualIconWidthDomain.swift b/TalerWallet1/Helper/EqualIconWidthDomain.swift new file mode 100644 index 0000000..e8c1e74 --- /dev/null +++ b/TalerWallet1/Helper/EqualIconWidthDomain.swift @@ -0,0 +1,141 @@ +/* MIT License + * Copyright (c) 2021 rob mayoff + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import SwiftUI + +fileprivate struct IconWidthKey: PreferenceKey { + static var defaultValue: CGFloat? { nil } + + static func reduce(value: inout CGFloat?, nextValue: () -> CGFloat?) { + switch (value, nextValue()) { + case (nil, let next): value = next + case (_, nil): break + case (.some(let current), .some(let next)): value = max(current, next) + } + } +} + +extension IconWidthKey: EnvironmentKey { } + +extension EnvironmentValues { + fileprivate var iconWidth: CGFloat? { + get { self[IconWidthKey.self] } + set { self[IconWidthKey.self] = newValue } + } +} + +fileprivate struct IconWidthModifier: ViewModifier { + @Environment(\.iconWidth) var width + + func body(content: Content) -> some View { + content + .background(GeometryReader { proxy in + Color.clear + .preference(key: IconWidthKey.self, value: proxy.size.width) + }) + .frame(width: width) + } +} + +struct EqualIconWidthLabelStyle: LabelStyle { + func makeBody(configuration: Configuration) -> some View { + HStack { + configuration.icon.modifier(IconWidthModifier()) + configuration.title //(alignment: .leading) + .multilineTextAlignment(.leading) + } + } +} + +struct EqualIconWidthDomain<Content: View>: View { + let content: Content + @State var iconWidth: CGFloat? = nil + + init(@ViewBuilder _ content: () -> Content) { + self.content = content() + } + + var body: some View { + content + .environment(\.iconWidth, iconWidth) + .onPreferenceChange(IconWidthKey.self) { self.iconWidth = $0 } + .labelStyle(EqualIconWidthLabelStyle()) + } +} +// MARK: - +#if DEBUG +struct Demo1View: View { + var body: some View { + VStack(alignment: .leading) { + VStack(alignment: .leading) { + Label("People", systemImage: "person.3") + Label("Star", systemImage: "star") + Label("This is a plane", systemImage: "airplane") + } + .padding() + EqualIconWidthDomain { + VStack(alignment: .leading) { + Label("People", systemImage: "person.3") + Label("Star", systemImage: "star") + Label("This is a plane", systemImage: "airplane") + } + } + } + } +} + +struct Demo1_Previews: PreviewProvider { + static var previews: some View { + Demo1View() + } +} + + +struct FancyView: View { + var body: some View { + EqualIconWidthDomain { + VStack { + Text("Le Menu") + .font(.caption) + Divider() + HStack { + VStack(alignment: .leading) { + Label( + title: { Text("Strawberry") }, + icon: { Text("🍓") }) + Label("Money", systemImage: "banknote") + } + VStack(alignment: .leading) { + Label("People", systemImage: "person.3") + Label("Star", systemImage: "star") + } + } + } + } + } +} + +struct Demo2_Previews: PreviewProvider { + static var previews: some View { + FancyView() + } +} +#endif diff --git a/TalerWallet1/Helper/KeyboardResponder.swift b/TalerWallet1/Helper/KeyboardResponder.swift new file mode 100644 index 0000000..c5d9cde --- /dev/null +++ b/TalerWallet1/Helper/KeyboardResponder.swift @@ -0,0 +1,45 @@ +// MIT License +// Copyright © Nicolai Harbo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software +// and associated documentation files (the "Software"), to deal in the Software without restriction, +// including without limitation the rights to use, copy, modify, merge, publish, distribute, +// sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// +import Combine +import UIKit + +public final class KeyboardResponder: ObservableObject { + + @Published public var keyboardHeight: CGFloat = 0 + var showCancellable: AnyCancellable? + var hideCancellable: AnyCancellable? + + public init() { + showCancellable = NotificationCenter.default.publisher(for: UIResponder.keyboardWillShowNotification) + .map { notification in + (notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? NSValue)?.cgRectValue.height ?? 0.0 + } + .receive(on: DispatchQueue.main) + .sink(receiveValue: { height in +// print("keyboard height: \(height)") + self.keyboardHeight = height + }) + + hideCancellable = NotificationCenter.default.publisher(for: UIResponder.keyboardWillHideNotification) + .receive(on: DispatchQueue.main) + .sink(receiveValue: { _ in + self.keyboardHeight = 0 + }) + } +} diff --git a/TalerWallet1/Helper/LocalizedAlertError.swift b/TalerWallet1/Helper/LocalizedAlertError.swift new file mode 100644 index 0000000..00070e5 --- /dev/null +++ b/TalerWallet1/Helper/LocalizedAlertError.swift @@ -0,0 +1,54 @@ +// MIT License +// Copyright © Antoine van der Lee +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software +// and associated documentation files (the "Software"), to deal in the Software without restriction, +// including without limitation the rights to use, copy, modify, merge, publish, distribute, +// sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// +import SwiftUI + + +struct LocalizedAlertError: LocalizedError { + let underlyingError: LocalizedError + var errorDescription: String? { + underlyingError.errorDescription + } + var recoverySuggestion: String? { + underlyingError.recoverySuggestion + } + + init?(error: Error?) { + guard let localizedError = error as? LocalizedError else { return nil } + underlyingError = localizedError + } +} + +extension View { + + @ViewBuilder + func errorAlert(error: Binding<Error?>, + buttonTitle: LocalizedStringKey = "xloc.generic.ok", + action: (() -> Void)? = nil) -> some View + { + let localizedAlertError = LocalizedAlertError(error: error.wrappedValue) + alert(isPresented: .constant(localizedAlertError != nil), error: localizedAlertError) { _ in + Button(buttonTitle) { + action?() + error.wrappedValue = nil + } + } message: { error in + Text(error.failureReason ?? error.recoverySuggestion ?? "") + } + } +} diff --git a/TalerWallet1/Helper/PublicConstants.swift b/TalerWallet1/Helper/PublicConstants.swift deleted file mode 100644 index 01680a0..0000000 --- a/TalerWallet1/Helper/PublicConstants.swift +++ /dev/null @@ -1,24 +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 - -public let EXCHANGEBASEURL = "exchangeBaseUrl" - -public let WITHDRAWAL = "withdrawal" -public let PAYMENT = "payment" -public let REFUND = "refund" -public let TIP = "tip" -public let REFRESH = "refresh" diff --git a/TalerWallet1/Helper/TalerDater.swift b/TalerWallet1/Helper/TalerDater.swift index 5a7abdb..de694a7 100644 --- a/TalerWallet1/Helper/TalerDater.swift +++ b/TalerWallet1/Helper/TalerDater.swift @@ -1,23 +1,12 @@ /* - * 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/> + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md */ import Foundation import taler_swift public class TalerDater: DateFormatter { - public static var shared = TalerDater() + public static let shared = TalerDater() static func relativeDate(from: TimeInterval) -> String? { if from > 0 { // transactions should always be in the past @@ -42,7 +31,7 @@ public class TalerDater: DateFormatter { if day < 14 { return "More than a week ago" } // will fall thru... return nil - } else { // Yikes! transaction date is in the future + } else { // Yikes❗️ transaction date is in the future return nil } } @@ -78,7 +67,7 @@ public class TalerDater: DateFormatter { } } return shared.string(from: date) - } catch { + } catch { // Never return "Never" } } @@ -100,3 +89,19 @@ public class TalerDater: DateFormatter { fatalError("init(coder:) has not been implemented") } } + +extension Date { + static func - (lhs: Date, rhs: Date) -> TimeInterval { + return lhs.timeIntervalSinceReferenceDate - rhs.timeIntervalSinceReferenceDate + } +} +extension TimeInterval { + + var seconds: Int { + return Int(self.rounded()) + } + + var milliseconds: Int { + return Int(self * 1000) + } +} diff --git a/TalerWallet1/Helper/TalerStrings.swift b/TalerWallet1/Helper/TalerStrings.swift index b642798..d1443b4 100644 --- a/TalerWallet1/Helper/TalerStrings.swift +++ b/TalerWallet1/Helper/TalerStrings.swift @@ -1,17 +1,6 @@ /* - * 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/> + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md */ import Foundation diff --git a/TalerWallet1/Helper/URL+iban.swift b/TalerWallet1/Helper/URL+iban.swift new file mode 100644 index 0000000..ca4689c --- /dev/null +++ b/TalerWallet1/Helper/URL+iban.swift @@ -0,0 +1,37 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +import SwiftUI + +extension URL: Identifiable { + public var id: URL {self} +} + +extension URL { + init(_ string: StaticString) { + self.init(string: "\(string)")! + } + + var iban: String? { + /// https://datatracker.ietf.org/doc/rfc8905/ + /// payto://iban/DE75512108001245126199?amount=EUR:200.0&message=hello + if scheme == "payto" && host == "iban" { + return lastPathComponent + } + return nil + } + + /// SwifterSwift: Dictionary of the URL's query parameters. + var queryParameters: [String: String]? { + guard let components = URLComponents(url: self, resolvingAgainstBaseURL: false), + let queryItems = components.queryItems else { return nil } + + var items: [String: String] = [:] + + for queryItem in queryItems { + items[queryItem.name] = queryItem.value + } + return items + } +} diff --git a/TalerWallet1/Helper/View+dismissTop.swift b/TalerWallet1/Helper/View+dismissTop.swift index bf6721e..ffd9c31 100644 --- a/TalerWallet1/Helper/View+dismissTop.swift +++ b/TalerWallet1/Helper/View+dismissTop.swift @@ -1,20 +1,80 @@ -/* - * 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/> - */ +// MIT License +// Copyright © Nicolai Harbo +// +// Permission is hereby granted, free of charge, to any person obtaining a copy of this software +// and associated documentation files (the "Software"), to deal in the Software without restriction, +// including without limitation the rights to use, copy, modify, merge, publish, distribute, +// sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in all copies or +// substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING +// BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, +// DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +// import SwiftUI +// John Sundell +extension View { + func onNotification( + _ notificationName: Notification.Name, + perform action: @escaping () -> Void + ) -> some View { + onReceive(NotificationCenter.default + .publisher(for: notificationName) + ) { _ in + action() + } + } + func onNotification( + _ notificationName: Notification.Name, + perform action: @escaping (_ notification: Notification) -> Void + ) -> some View { + onReceive(NotificationCenter.default + .publisher(for: notificationName) + ) { notification in + action(notification) + } + } + + func onNotificationM( // M for main thread + _ notificationName: Notification.Name, + perform action: @escaping () -> Void + ) -> some View { + onReceive(NotificationCenter.default + .publisher(for: notificationName) + .receive(on: RunLoop.main) + ) { _ in + action() + } + } + + func onNotificationM( // M for main thread + _ notificationName: Notification.Name, + perform action: @escaping (_ notification: Notification) -> Void + ) -> some View { + onReceive(NotificationCenter.default + .publisher(for: notificationName) + .receive(on: RunLoop.main) + ) { notification in + action(notification) + } + } + + func onAppEnteredBackground( + perform action: @escaping () -> Void + ) -> some View { + onNotification( + UIApplication.didEnterBackgroundNotification, + perform: action + ) + } +} + /// 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. diff --git a/TalerWallet1/Helper/WalletColors.swift b/TalerWallet1/Helper/WalletColors.swift new file mode 100644 index 0000000..1f037cf --- /dev/null +++ b/TalerWallet1/Helper/WalletColors.swift @@ -0,0 +1,56 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +import SwiftUI + +public struct WalletColors { + + let tint = Color(.tintColor) + let gray1 = Color(.systemGray) + let gray2 = Color(.systemGray2) + let gray3 = Color(.systemGray3) + let gray4 = Color(.systemGray4) + let gray5 = Color(.systemGray5) + let gray6 = Color(.systemGray6) + + func buttonForeColor(pressed: Bool, disabled: Bool, prominent: Bool = false) -> Color { + disabled ? gray2 + : !prominent ? tint + : pressed ? gray6 : gray5 + } + + func buttonBackColor(pressed: Bool, disabled: Bool, prominent: Bool = false) -> Color { + disabled ? gray5 + : prominent ? tint + : pressed ? gray5 : gray4 + } + + var backgroundColor: Color { + gray6 + } + + var sideBackground: Color { + gray5 + } + + var fieldForeground: Color { // text color + Color.primary + } + var fieldBackground: Color { + Color(.systemBackground) + } + + var uncompletedColor: Color { + gray1 + } + func pendingColor(_ incoming: Bool) -> Color { + incoming ? Color("PendingIncoming") + : Color("PendingOutgoing") + } + func transactionColor(_ incoming: Bool) -> Color { + incoming ? Color("Incoming") + : Color("Outgoing") + } + +} diff --git a/TalerWallet1/Views/Balances/BalancesModel.swift b/TalerWallet1/Model/BalancesModel.swift index 80197d9..525aabf 100644 --- a/TalerWallet1/Views/Balances/BalancesModel.swift +++ b/TalerWallet1/Model/BalancesModel.swift @@ -1,25 +1,19 @@ /* - * 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/> + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md */ import Foundation import taler_swift -import SymLog fileprivate let ASYNCDELAY: UInt = 0 //set e.g to 6 or 9 seconds for debugging +// MARK: - class BalancesModel: WalletModel { - @Published var balances: [Balance]? // update view + @Published var balances: [Balance] + + override init(_ symbol: Int = -1) { + balances = [] // empty, but not nil + super.init(symbol) + } } // MARK: - /// A request to get the balances held in the wallet. @@ -41,6 +35,7 @@ struct Balance: Decodable, Hashable { var pendingOutgoing: Amount var hasPendingTransactions: Bool var requiresUserInput: Bool + var scopeInfo: ScopeInfo public static func == (lhs: Balance, rhs: Balance) -> Bool { return lhs.available == rhs.available && @@ -61,13 +56,32 @@ struct Balance: Decodable, Hashable { // MARK: - extension BalancesModel { /// fetch Balances from Wallet-Core. No networking involved - @MainActor func fetchBalances() async throws { + @MainActor func fetchBalancesM() + async { // M for MainActor do { let request = GetBalances() - let response = try await sendRequest(request, ASYNCDELAY) - balances = response.balances // trigger view update in CurrenciesListView + let response = try await sendRequestM(request, ASYNCDELAY) + balances = response.balances // trigger view update in BalancesListView } catch { - throw error + balances = [] + } + } +} + +// MARK: - +extension BalancesModel { + private static var currencies: [String] = [] // names of currencies + private static var models: [BalancesModel] = [] // one model per currency + + static func model(currency: String) -> BalancesModel { + if let index = BalancesModel.currencies.firstIndex(of:currency) { + let model = BalancesModel.models[index] + return model + } else { // new currency + let model = BalancesModel() + BalancesModel.models.append(model) + BalancesModel.currencies.append(currency) + return model } } } diff --git a/TalerWallet1/Views/Exchange/ExchangeModel.swift b/TalerWallet1/Model/ExchangeModel.swift index c16e720..fd2a5d5 100644 --- a/TalerWallet1/Views/Exchange/ExchangeModel.swift +++ b/TalerWallet1/Model/ExchangeModel.swift @@ -1,17 +1,6 @@ /* - * 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/> + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md */ import Foundation import taler_swift @@ -19,36 +8,16 @@ 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 + @Published var exchanges: [Exchange] - struct Response: Decodable { // list of known exchanges - var exchanges: [Exchange] + override init(_ symbol: Int = -1) { + exchanges = [] // empty, but not nil + super.init(symbol) } } - -/// 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 { +/// The result from wallet-core's ListExchanges +struct Exchange: Codable, Hashable, Identifiable { static func == (lhs: Exchange, rhs: Exchange) -> Bool { return lhs.exchangeBaseUrl == rhs.exchangeBaseUrl && lhs.exchangeStatus == rhs.exchangeStatus && @@ -57,13 +26,16 @@ struct Exchange: Codable, Hashable { var exchangeBaseUrl: String var currency: String? - var tosStatus: String var paytoUris: [String] + var tosStatus: String var exchangeStatus: String - var permanent: Bool var ageRestrictionOptions: [Int] + var permanent: Bool var lastUpdateErrorInfo: ExchangeError? + var id: String { + exchangeBaseUrl + } var name: String? { if let url = URL(string: exchangeBaseUrl) { if let host = url.host { @@ -73,43 +45,88 @@ struct Exchange: Codable, Hashable { return nil } } + struct ExchangeError: Codable, Hashable { var error: HTTPError } struct HTTPError: Codable, Hashable { var code: Int - var requestUrl: String + var requestUrl: String? var hint: String - var requestMethod: String + var requestMethod: String? var httpStatusCode: Int? + var when: Timestamp? + var stack: String? +} +// 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: - extension ExchangeModel { /// ask wallet-core for its list of known exchanges - @MainActor func updateList() async throws { + @MainActor func updateListM() + async throws { // M for MainActor do { let request = ListExchanges() - let response = try await sendRequest(request, ASYNCDELAY) + let response = try await sendRequestM(request, ASYNCDELAY) exchanges = response.exchanges // trigger view update in ExchangeListView - } catch { // TODO: Error + } catch { // rethrows symLog?.log(error.localizedDescription) throw error } } - /// add a new exchange with URL to wallet's list of known exchanges + /// add a new exchange with URL to the 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) + _ = try await sendRequestT(request) // TODO: MainActor? symLog?.log("added exchange: \(url)") - try await updateList() - } catch { // TODO: Error + try await updateListM() + } catch { // rethrows symLog?.log(error.localizedDescription) throw error } } } + +// MARK: - +extension ExchangeModel { + private static var models: [ExchangeModel] = [] // a list of models even though I currently need only one + + static func model() -> ExchangeModel { + if ExchangeModel.models.count > 0 { + let model = ExchangeModel.models[0] + return model + } else { // new model + let model = ExchangeModel() + ExchangeModel.models.append(model) + return model + } + } +} diff --git a/TalerWallet1/Views/Payment/PaymentURIModel.swift b/TalerWallet1/Model/PaymentURIModel.swift index 8fd4142..dea406c 100644 --- a/TalerWallet1/Views/Payment/PaymentURIModel.swift +++ b/TalerWallet1/Model/PaymentURIModel.swift @@ -1,17 +1,6 @@ /* - * 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/> + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md */ import Foundation import taler_swift @@ -29,6 +18,9 @@ enum PaymentState { class PaymentURIModel: WalletModel { @Published var paymentState: PaymentState? + override init(_ symbol: Int = -1) { // init with 0 to disable logging for this class + super.init(symbol) + } } @@ -114,7 +106,7 @@ struct Extra: Codable { // MARK: - /// The result from PreparePayForUri struct PaymentDetailsForUri: Codable { - let status: String +// let status: String let amountRaw: Amount let amountEffective: Amount let noncePriv: String @@ -134,7 +126,7 @@ fileprivate struct PreparePayForUri: WalletBackendFormattedRequest { } } // MARK: - -/// The result from getPaymentDetailsForAmount +/// The result from confirmPayForUri struct ConfirmPayResult: Decodable { var type: String var contractTerms: ContractTerms @@ -155,29 +147,47 @@ fileprivate struct confirmPayForUri: WalletBackendFormattedRequest { extension PaymentURIModel { /// load payment details. Networking involved @MainActor - func preparePayForUri(_ talerPayUri: String) async throws -> PaymentDetailsForUri { + func preparePayForUriM(_ talerPayUri: String) // M for MainActor + async throws -> PaymentDetailsForUri { do { paymentState = .waitingForUriDetails let request = PreparePayForUri(talerPayUri: talerPayUri) - let response = try await sendRequest(request, ASYNCDELAY) + let response = try await sendRequestM(request, ASYNCDELAY) // TODO: MainActor ? paymentState = .receivedUriDetails return response - } catch { + } catch { // rethrows paymentState = .error throw error } } @MainActor - func confirmPay(_ proposalId: String) async throws -> ConfirmPayResult { + func confirmPayM(_ proposalId: String) // M for MainActor + async throws -> ConfirmPayResult { do { paymentState = .waitingForPaymentAck let request = confirmPayForUri(proposalId: proposalId) - let response = try await sendRequest(request, ASYNCDELAY) + let response = try await sendRequestM(request, ASYNCDELAY) // TODO: MainActor ? paymentState = .receivedPaymentAck return response - } catch { + } catch { // rethrows paymentState = .error throw error } } } + +// MARK: - +extension PaymentURIModel { + private static var models: [PaymentURIModel] = [] // a list of models even though I currently need only one + + static func model() -> PaymentURIModel { + if PaymentURIModel.models.count > 0 { + let model = PaymentURIModel.models[0] + return model + } else { // new model + let model = PaymentURIModel() + PaymentURIModel.models.append(model) + return model + } + } +} diff --git a/TalerWallet1/Model/Peer2peerModel.swift b/TalerWallet1/Model/Peer2peerModel.swift new file mode 100644 index 0000000..490915c --- /dev/null +++ b/TalerWallet1/Model/Peer2peerModel.swift @@ -0,0 +1,130 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +import Foundation +import taler_swift +import AnyCodable +//import SymLog +fileprivate let ASYNCDELAY: UInt = 0 //set e.g to 6 or 9 seconds for debugging + +class Peer2peerModel: WalletModel { + override init(_ symbol: Int = -1) { // init with 0 to disable logging for this class + super.init(symbol) + } +} +// MARK: - PeerContractTerms +struct PeerContractTerms: Codable { + let amount: Amount + let summary: String + let purse_expiration: Timestamp +} +// MARK: - +/// The result from CheckPeerPushDebit +struct CheckPeerPushDebitResponse: Codable { + let amountEffective: Amount + let amountRaw: Amount +// let maxExpirationDate: Timestamp // TODO: limit expiration (30 days or 7 days) +} +/// A request to check fees before sending coins to another wallet. +fileprivate struct CheckPeerPushDebit: WalletBackendFormattedRequest { + typealias Response = CheckPeerPushDebitResponse + func operation() -> String { return "checkPeerPushDebit" } + func args() -> Args { return Args(amount: amount) } + + var amount: Amount + struct Args: Encodable { + var amount: Amount + } +} +// MARK: - +/// The result from CheckPeerPullCredit +struct CheckPeerPullCreditResponse: Codable { + let scopeInfo: ScopeInfo? + let exchangeBaseUrl: String? + let amountEffective: Amount + let amountRaw: Amount +} +/// A request to check fees before invoicing another wallet. +fileprivate struct CheckPeerPullCredit: WalletBackendFormattedRequest { + typealias Response = CheckPeerPullCreditResponse + func operation() -> String { return "checkPeerPullCredit" } + func args() -> Args { return Args(exchangeBaseUrl: exchangeBaseUrl, scopeInfo: scopeInfo, amount: amount) } + + var exchangeBaseUrl: String? + var scopeInfo: ScopeInfo? + var amount: Amount + struct Args: Encodable { + var exchangeBaseUrl: String? + var scopeInfo: ScopeInfo? + var amount: Amount + } +} +// MARK: - +/// The result from InitiatePeerPushDebit +struct PeerPushResponse: Codable { + let contractPriv: String + let mergePriv: String + let pursePub: String + let exchangeBaseUrl: String + let talerUri: String + let transactionId: String +} +/// A request to send coins to another wallet. +fileprivate struct InitiatePeerPushDebit: WalletBackendFormattedRequest { + typealias Response = PeerPushResponse + func operation() -> String { return "initiatePeerPushDebit" } + func args() -> Args { return Args(exchangeBaseUrl: exchangeBaseUrl, + partialContractTerms: partialContractTerms) } + + var exchangeBaseUrl: String? + var partialContractTerms: PeerContractTerms + struct Args: Encodable { + var exchangeBaseUrl: String? + var partialContractTerms: PeerContractTerms + } +} +// MARK: - +extension Peer2peerModel { + /// query exchange for fees (sending coins). Networking involved + @MainActor + func checkPeerPushDebitM(_ amount: Amount) // M for MainActor + async throws -> CheckPeerPushDebitResponse { + let request = CheckPeerPushDebit(amount: amount) + let response = try await sendRequestM(request, ASYNCDELAY) + return response + } + /// query exchange for fees (invoice coins). Networking involved + @MainActor + func checkPeerPullCreditM(_ amount: Amount, exchangeBaseUrl: String?) // M for MainActor + async throws -> CheckPeerPullCreditResponse { + let request = CheckPeerPullCredit(exchangeBaseUrl: exchangeBaseUrl, amount: amount) + let response = try await sendRequestM(request, ASYNCDELAY) + return response + } + /// generate peer-push. Networking involved + @MainActor + func initiatePeerPushDebitM(_ baseURL: String, terms: PeerContractTerms) // M for MainActor + async throws -> PeerPushResponse { + let request = InitiatePeerPushDebit(exchangeBaseUrl: baseURL, + partialContractTerms: terms) + let response = try await sendRequestM(request, ASYNCDELAY) + return response + } +} + +// MARK: - +extension Peer2peerModel { + private static var models: [Peer2peerModel] = [] // a list of models even though I currently need only one + + static func model() -> Peer2peerModel { + if Peer2peerModel.models.count > 0 { + let model = Peer2peerModel.models[0] + return model + } else { // new model + let model = Peer2peerModel() + Peer2peerModel.models.append(model) + return model + } + } +} diff --git a/TalerWallet1/Model/PendingModel.swift b/TalerWallet1/Model/PendingModel.swift new file mode 100644 index 0000000..764f799 --- /dev/null +++ b/TalerWallet1/Model/PendingModel.swift @@ -0,0 +1,78 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +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] + + override init(_ symbol: Int = -1) { + pendingOperations = [] // empty, but not nil + super.init(symbol) + } +} +// 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) + } + +} +// MARK: - +extension PendingModel { + @MainActor func updateM() + async throws { // M for MainActor + do { + let request = GetPendingOperations() + let response = try await sendRequestM(request, ASYNCDELAY) + pendingOperations = response.pendingOperations + } + } +} + +// MARK: - +extension PendingModel { + private static var models: [PendingModel] = [] // a list of models even though I currently need only one + + static func model() -> PendingModel { + if PendingModel.models.count > 0 { + let model = PendingModel.models[0] + return model + } else { // new model + let model = PendingModel() + PendingModel.models.append(model) + return model + } + } +} diff --git a/TalerWallet1/Model/ExchangeTestModel.swift b/TalerWallet1/Model/SettingsModel.swift index 98702d5..0172cf6 100644 --- a/TalerWallet1/Model/ExchangeTestModel.swift +++ b/TalerWallet1/Model/SettingsModel.swift @@ -1,61 +1,59 @@ /* - * 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/> + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md */ import Foundation import taler_swift import SymLog fileprivate let ASYNCDELAY: UInt = 0 //set e.g to 6 or 9 seconds for debugging -fileprivate let DEMO_EXCHANGEBASEURL = "https://exchange.demo.taler.net/" -fileprivate let DEMO_BANKBASEURL = "https://bank.demo.taler.net/" -fileprivate let DEMO_BANKAPIBASEURL = "https://bank.demo.taler.net/demobanks/default/access-api/" +fileprivate let DEMO_EXCHANGEBASEURL = DEMOEXCHANGE // "https://exchange.demo.taler.net/" +fileprivate let DEMO_BANKBASEURL = DEMOBANK // "https://bank.demo.taler.net/" +fileprivate let DEMO_BANKAPIBASEURL = DEMOBANK + "/demobanks/default/access-api/" fileprivate let DEMO_MERCHANTBASEURL = "https://backend.demo.taler.net/" fileprivate let DEMO_MERCHANTAUTHTOKEN = "secret-token:sandbox" // MARK: - -class ExchangeTestModel: WalletModel { +class SettingsModel: WalletModel { + override init(_ symbol: Int = -1) { // init with 0 to disable logging for this class + super.init(symbol) + } } // MARK: - -extension ExchangeTestModel { - @MainActor func loadTestKudos() async throws { +extension SettingsModel { + @MainActor func loadTestKudosM() + async throws { // M for MainActor do { - let amount = Amount(currency: "KUDOS", integer: 11, fraction: 0) + let amount = Amount(currency: DEMOCURRENCY, integer: 11, fraction: 0) let request = WalletBackendWithdrawTestBalance(amount: amount, bankBaseUrl: DEMO_BANKBASEURL, exchangeBaseUrl: DEMO_EXCHANGEBASEURL, bankAccessApiBaseUrl: DEMO_BANKAPIBASEURL) - let response = try await sendRequest(request, ASYNCDELAY) + let response = try await sendRequestM(request, ASYNCDELAY) symLog?.log("received: \(response)") - } catch { + } catch { // rethrows + symLog?.log(error.localizedDescription) throw error } } - @MainActor func runIntegrationTest() async throws { + @MainActor func runIntegrationTestM(newVersion: Bool) + async throws { // M for MainActor do { - let amountW = Amount(currency: "KUDOS", integer: 3, fraction: 0) - let amountS = Amount(currency: "KUDOS", integer: 1, fraction: 0) - let request = WalletBackendRunIntegration(amountToWithdraw: amountW, - amountToSpend: amountS, - bankBaseUrl: DEMO_BANKAPIBASEURL, - exchangeBaseUrl: DEMO_EXCHANGEBASEURL, - merchantBaseUrl: DEMO_MERCHANTBASEURL, - merchantAuthToken: DEMO_MERCHANTAUTHTOKEN) - let response = try await sendRequest(request, ASYNCDELAY) - symLog?.log("received: \(response)") - } catch { + let amountW = Amount(currency: DEMOCURRENCY, integer: 3, fraction: 0) + let amountS = Amount(currency: DEMOCURRENCY, integer: 1, fraction: 0) + let request = WalletBackendRunIntegration(newVersion: newVersion, + amountToWithdraw: amountW, + amountToSpend: amountS, + bankBaseUrl: DEMO_BANKAPIBASEURL, + bankAccessApiBaseUrl: DEMO_BANKAPIBASEURL, + exchangeBaseUrl: DEMO_EXCHANGEBASEURL, + merchantBaseUrl: DEMO_MERCHANTBASEURL, + merchantAuthToken: DEMO_MERCHANTAUTHTOKEN) + let _ = try await sendRequestT(request, ASYNCDELAY) + symLog?.log("runIntegrationTest finished") + } catch { // rethrows + symLog?.log(error.localizedDescription) throw error } } @@ -85,21 +83,25 @@ fileprivate struct WalletBackendWithdrawTestBalance: WalletBackendFormattedReque /// A request to add a test balance to the wallet. fileprivate struct WalletBackendRunIntegration: WalletBackendFormattedRequest { - typealias Response = String - func operation() -> String { return "runIntegrationTest" } + struct Response: Decodable {} + func operation() -> String { return newVersion ? "runIntegrationTestV2" : "runIntegrationTest" } func args() -> Args { return Args(amountToWithdraw: amountToWithdraw, amountToSpend: amountToSpend, bankBaseUrl: bankBaseUrl, + bankAccessApiBaseUrl: bankAccessApiBaseUrl, exchangeBaseUrl: exchangeBaseUrl, merchantBaseUrl: merchantBaseUrl, merchantAuthToken: merchantAuthToken ) } + let newVersion: Bool + var amountToWithdraw: Amount var amountToSpend: Amount var bankBaseUrl: String + var bankAccessApiBaseUrl: String var exchangeBaseUrl: String var merchantBaseUrl: String var merchantAuthToken: String @@ -108,6 +110,7 @@ fileprivate struct WalletBackendRunIntegration: WalletBackendFormattedRequest { var amountToWithdraw: Amount var amountToSpend: Amount var bankBaseUrl: String + var bankAccessApiBaseUrl: String var exchangeBaseUrl: String var merchantBaseUrl: String var merchantAuthToken: String diff --git a/TalerWallet1/Model/TransactionsModel.swift b/TalerWallet1/Model/TransactionsModel.swift new file mode 100644 index 0000000..668f3c3 --- /dev/null +++ b/TalerWallet1/Model/TransactionsModel.swift @@ -0,0 +1,157 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +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] + + static func specialTransactions(_ transactions: [Transaction]) -> [Transaction] { + transactions.filter { transaction in + transaction.isSpecial + } + } + var specialTransactions: [Transaction] { + transactions.filter { transaction in + transaction.isSpecial + } + } + + static func completedTransactions(_ transactions: [Transaction]) -> [Transaction] { + transactions.filter { transaction in + transaction.isDone + } + } + static func pendingTransactions(_ transactions: [Transaction]) -> [Transaction] { + transactions.filter { transaction in + transaction.isPending + } + } + static func uncompletedTransactions(_ transactions: [Transaction]) -> [Transaction] { + transactions.filter { transaction in + !transaction.isDone && !transaction.isPending + } + } + + override init(_ symbol: Int = -1) { + transactions = [] // empty, but not nil + super.init(symbol) + } +} + +// MARK: - +/// A request to get the transactions in the wallet's history. +fileprivate struct GetTransactions: WalletBackendFormattedRequest { + func operation() -> String { return "getTransactions" } +// func operation() -> String { return "testingGetSampleTransactions" } + 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] + } +} +/// A request to abort a wallet transaction by ID. +struct AbortTransaction: WalletBackendFormattedRequest { + func operation() -> String { return "abortTransaction" } + func args() -> Args { return Args(transactionId: transactionId) } + + var transactionId: String + + struct Args: Encodable { + var transactionId: String + } + + struct Response: Decodable {} +} +/// A request to delete a wallet transaction by ID. +struct DeleteTransaction: WalletBackendFormattedRequest { + struct Response: Decodable {} + func operation() -> String { return "deleteTransaction" } + func args() -> Args { return Args(transactionId: transactionId) } + + var transactionId: String + + struct Args: Encodable { + var transactionId: String + } +} + +// MARK: - +extension TransactionsModel { + /// ask wallet-core for its list of transactions filtered by searchString + func fetchTransactions(currency: String?) async { // might be called from a background thread itself + await fetchTransactionsM(currency: currency, searchString: nil) + } + /// fetch transactions from Wallet-Core. No networking involved + @MainActor func fetchTransactionsM(currency: String? = nil, searchString: String? = nil) + async { // M for MainActor + do { + let request = GetTransactions(currency: currency, search: searchString) + let response = try await sendRequestM(request, ASYNCDELAY) + transactions = response.transactions // trigger view update in TransactionsListView + } catch { + transactions = [] + } + } + + func abortTransaction(transactionId: String) async throws { // might be called from a background thread itself + try await abortTransactionM(transactionId: transactionId) // call deleteTransactionM on main thread + } + /// delete the specified transaction from Wallet-Core. No networking involved + @MainActor func abortTransactionM(transactionId: String) + async throws { // M for MainActor + do { + let request = AbortTransaction(transactionId: transactionId) + let _ = try await sendRequestT(request, ASYNCDELAY) + } catch { // rethrows + symLog?.log(error.localizedDescription) + throw error + } + } + + func deleteTransaction(transactionId: String) async throws { // might be called from a background thread itself + try await deleteTransactionM(transactionId: transactionId) // call deleteTransactionM on main thread + } + /// delete the specified transaction from Wallet-Core. No networking involved + @MainActor func deleteTransactionM(transactionId: String) + async throws { // M for MainActor + do { + let request = DeleteTransaction(transactionId: transactionId) + let _ = try await sendRequestT(request, ASYNCDELAY) + } catch { // rethrows + symLog?.log(error.localizedDescription) + throw error + } + } +} + +// MARK: - +extension TransactionsModel { + private static var currencies: [String] = [] // names of currencies + private static var models: [TransactionsModel] = [] // one model per currency + + static func model(currency: String) -> TransactionsModel { + if let index = TransactionsModel.currencies.firstIndex(of:currency) { + let model = TransactionsModel.models[index] + return model + } else { // new currency + let model = TransactionsModel() + TransactionsModel.models.append(model) + TransactionsModel.currencies.append(currency) + return model + } + } +} diff --git a/TalerWallet1/Model/WalletInitModel.swift b/TalerWallet1/Model/WalletInitModel.swift index 7be0dff..d977890 100644 --- a/TalerWallet1/Model/WalletInitModel.swift +++ b/TalerWallet1/Model/WalletInitModel.swift @@ -1,25 +1,16 @@ /* - * 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/> + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md */ import Foundation import SymLog -let DATABASE = "talerwalletdb-v30" +private let DATABASE = "talerwalletdb-v30" class WalletInitModel: WalletModel { - + override init(_ symbol: Int = -1) { // init with 0 to disable logging for this class + super.init(symbol) + } } // MARK: - /// A request to initialize Wallet-core @@ -39,11 +30,8 @@ fileprivate struct WalletBackendInitRequest: WalletBackendFormattedRequest { var persistentStoragePath: String - struct Response: Decodable { // versioninfo + struct Response: Decodable { var versionInfo: VersionInfo - enum CodingKeys: String, CodingKey { - case versionInfo = "versionInfo" - } } } // MARK: - @@ -59,14 +47,15 @@ struct VersionInfo: Decodable { // MARK: - extension WalletInitModel { /// initalize Wallet-Core. Will do networking - func initWallet() async throws -> VersionInfo? { + func initWalletT() // T for any Thread + 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 + let response = try await sendRequestT(request, 0) // no Delay return response.versionInfo - } catch { + } catch { // rethrows symLog?.log("error: \(error)") throw error } @@ -80,7 +69,7 @@ extension WalletInitModel { storageDir.appendPathExtension("json") return storageDir.path } else { // should never happen - symLog?.log("Yikes! documentURLs empty") // TODO: symLog.error + symLog?.log("Yikes❗️ documentURLs empty") // TODO: symLog.error throw WalletBackendError.initializationError } } diff --git a/TalerWallet1/Model/WalletModel.swift b/TalerWallet1/Model/WalletModel.swift index d340278..9fb0622 100644 --- a/TalerWallet1/Model/WalletModel.swift +++ b/TalerWallet1/Model/WalletModel.swift @@ -1,55 +1,87 @@ /* - * 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/> + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md */ import Foundation import SymLog fileprivate let ASYNCDELAY: UInt = 0 //set e.g to 6 or 9 seconds for debugging + +// MARK: - /// 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 + init(_ symbol: Int) { // init with 0 to disable logging for this class + self.symLog = SymLogC(symbol == 0 ? 0 : -1, funcName: Self.className()) } - @MainActor func sendRequest<T: WalletBackendFormattedRequest> (_ request: T, _ delay: UInt = 0) - async throws -> T.Response { + @MainActor func sendRequestM<T: WalletBackendFormattedRequest> (_ request: T, _ delay: UInt = 0) + async throws -> T.Response { // M for MainActor loading = true // enter progressView do { + let response = try await sendRequestT(request, delay) + loading = false // exit progressView + return response + } catch { // rethrows + loading = false // exit progressView + throw error + } + } + + func sendRequestT<T: WalletBackendFormattedRequest> (_ request: T, _ delay: UInt = 0) + async throws -> T.Response { // T for any Thread + let sendTime = Date.now + do { symLog?.log("sending: \(request)") - let (response, id) = try await walletCore.sendFormattedRequest(request: request) + let (response, id) = try await WalletCore.shared.sendFormattedRequest(request: request) + let timeUsed = Date.now - sendTime 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...") + symLog?.log("received: (\(id)) after \(timeUsed.milliseconds) ms, 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)") + symLog?.log("(\(id)) waking up again after \(asyncDelay) seconds, will deliver \(response)") } else { - symLog?.log("received: \(response)") + symLog?.log("received: (\(id)) after \(timeUsed.milliseconds) ms, \(response)") } - loading = false // exit progressView return response - } catch { + } catch { // rethrows + let timeUsed = Date.now - sendTime + symLog?.log("Yikes❗️ \(request.operation()) failed after \(timeUsed.milliseconds) ms") throw error } } + func getTransactionById(transactionId: String) async throws -> Transaction { // might be called from a background thread itself + return try await getTransactionByIdM(transactionId: transactionId) // call deleteTransactionM on main thread + } + /// get the specified transaction from Wallet-Core. No networking involved + @MainActor func getTransactionByIdM(transactionId: String) + async throws -> Transaction { // M for MainActor + do { + let request = GetTransactionById(transactionId: transactionId) + let response = try await sendRequestT(request, ASYNCDELAY) + return response + } catch { // rethrows + throw error + } + } } +// MARK: - +/// A request to get a wallet transaction by ID. +struct GetTransactionById: WalletBackendFormattedRequest { + typealias Response = Transaction + func operation() -> String { return "getTransactionById" } + func args() -> Args { return Args(transactionId: transactionId) } + + var transactionId: String + + struct Args: Encodable { + var transactionId: String + } +} + diff --git a/TalerWallet1/Views/Withdraw/WithdrawURIModel.swift b/TalerWallet1/Model/WithdrawModel.swift index f375a1d..b3a2161 100644 --- a/TalerWallet1/Views/Withdraw/WithdrawURIModel.swift +++ b/TalerWallet1/Model/WithdrawModel.swift @@ -1,17 +1,6 @@ /* - * 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/> + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md */ import Foundation import taler_swift @@ -32,16 +21,19 @@ enum WithdrawState { case receivedWithdrAck } -class WithdrawURIModel: WalletModel { +class WithdrawModel: WalletModel { @Published var withdrawState: WithdrawState? + override init(_ symbol: Int = -1) { // init with 0 to disable logging for this class + super.init(symbol) + } } // MARK: - /// The result from getWithdrawalDetailsForUri -struct WithdrawalDetailsForUri: Decodable { +struct WithdrawUriInfoResponse: Decodable { var amount: Amount - var defaultExchangeBaseUrl: String? - var possibleExchanges: [ExchangeListItem] + var defaultExchangeBaseUrl: String? // TODO: might be nil ❗️Yikes + var possibleExchanges: [ExchangeListItem] // TODO: query these for fees? } struct ExchangeListItem: Codable, Hashable { var exchangeBaseUrl: String @@ -62,7 +54,7 @@ struct ExchangeListItem: Codable, Hashable { } /// A request to get an exchange's withdrawal details. fileprivate struct GetWithdrawalDetailsForURI: WalletBackendFormattedRequest { - typealias Response = WithdrawalDetailsForUri + typealias Response = WithdrawUriInfoResponse func operation() -> String { return "getWithdrawalDetailsForUri" } func args() -> Args { return Args(talerWithdrawUri: talerWithdrawUri) } @@ -73,14 +65,17 @@ fileprivate struct GetWithdrawalDetailsForURI: WalletBackendFormattedRequest { } // MARK: - /// The result from getWithdrawalDetailsForAmount -struct WithdrawalDetailsForAmount: Decodable { - var tosAccepted: Bool +struct ManualWithdrawalDetails: Decodable { var amountRaw: Amount var amountEffective: Amount + var paytoUris: [String] + var tosAccepted: Bool + var ageRestrictionOptions: [Int]? + var numCoins: Int? } /// A request to get an exchange's withdrawal details. fileprivate struct GetWithdrawalDetailsForAmount: WalletBackendFormattedRequest { - typealias Response = WithdrawalDetailsForAmount + typealias Response = ManualWithdrawalDetails func operation() -> String { return "getWithdrawalDetailsForAmount" } func args() -> Args { return Args(exchangeBaseUrl: exchangeBaseUrl, amount: amount) } @@ -124,12 +119,14 @@ fileprivate struct SetExchangeTOSAccepted: WalletBackendFormattedRequest { } } // MARK: - -struct BankConfirmation: Decodable { - var bankConfirmationUrl: String? +struct AcceptWithdrawalResponse: Decodable { + var reservePub: String + var confirmTransferUrl: String? + var transactionId: String } /// A request to accept a bank-integrated withdrawl. fileprivate struct AcceptBankIntegratedWithdrawal: WalletBackendFormattedRequest { - typealias Response = BankConfirmation + typealias Response = AcceptWithdrawalResponse func operation() -> String { return "acceptBankIntegratedWithdrawal" } func args() -> Args { return Args(talerWithdrawUri: talerWithdrawUri, exchangeBaseUrl: exchangeBaseUrl) } @@ -142,72 +139,131 @@ fileprivate struct AcceptBankIntegratedWithdrawal: WalletBackendFormattedRequest } } // MARK: - -extension WithdrawURIModel { +struct AcceptManualWithdrawalResult: Decodable { + var reservePub: String + var exchangePaytoUris: [String] + var transactionId: String +} +/// A request to accept a manual withdrawl. +fileprivate struct AcceptManualWithdrawal: WalletBackendFormattedRequest { + typealias Response = AcceptManualWithdrawalResult + func operation() -> String { return "acceptManualWithdrawal" } + func args() -> Args { return Args(exchangeBaseUrl: exchangeBaseUrl, amount: amount, restrictAge: restrictAge) } + + var exchangeBaseUrl: String + var amount: Amount + var restrictAge: Int? + + struct Args: Encodable { + var exchangeBaseUrl: String + var amount: Amount + var restrictAge: Int? + } +} +// MARK: - +extension WithdrawModel { /// load withdrawal details. Networking involved @MainActor - func loadWithdrawalDetailsForURI(_ talerWithdrawUri: String) async throws -> WithdrawalDetailsForUri { + func loadWithdrawalDetailsForUriM(_ talerWithdrawUri: String) // M for MainActor + async throws -> WithdrawUriInfoResponse { do { withdrawState = .waitingForUriDetails let request = GetWithdrawalDetailsForURI(talerWithdrawUri: talerWithdrawUri) - let response = try await sendRequest(request, ASYNCDELAY) + let response = try await sendRequestM(request, ASYNCDELAY) // TODO: MainActor ? withdrawState = .receivedUriDetails return response - } catch { + } catch { // rethrows withdrawState = .error throw error } } @MainActor - func loadWithdrawalDetailsForAmount(_ detailsForUri: WithdrawalDetailsForUri) async throws -> WithdrawalDetailsForAmount { + func loadWithdrawalDetailsForAmountM(_ exchangeBaseUrl: String, amount: Amount) // M for MainActor + async throws -> ManualWithdrawalDetails { do { withdrawState = .waitingForAmountDetails - let baseURL = detailsForUri.defaultExchangeBaseUrl! - let request = GetWithdrawalDetailsForAmount(exchangeBaseUrl: baseURL, amount: detailsForUri.amount) - let response = try await sendRequest(request, ASYNCDELAY) + let request = GetWithdrawalDetailsForAmount(exchangeBaseUrl: exchangeBaseUrl, + amount: amount) + let response = try await sendRequestM(request, ASYNCDELAY) // TODO: MainActor ? withdrawState = .receivedAmountDetails return response - } catch { + } catch { // rethrows withdrawState = .error throw error } } @MainActor - func loadExchangeTermsOfService(_ exchangeBaseUrl: String) async throws -> ExchangeTermsOfService { + func loadExchangeTermsOfServiceM(_ exchangeBaseUrl: String) // M for MainActor + async throws -> ExchangeTermsOfService { do { withdrawState = .waitingForTOS let request = GetExchangeTermsOfService(exchangeBaseUrl: exchangeBaseUrl) - let response = try await sendRequest(request, ASYNCDELAY) + let response = try await sendRequestM(request, ASYNCDELAY) // TODO: MainActor ? withdrawState = .receivedTOS return response - } catch { + } catch { // rethrows withdrawState = .error throw error } } @MainActor - func setExchangeTOSAccepted(_ exchangeBaseUrl: String, etag: String) async throws -> Decodable { + func setExchangeTOSAcceptedM(_ exchangeBaseUrl: String, etag: String) // M for MainActor + async throws -> Decodable { do { withdrawState = .waitingForTOSAck let request = SetExchangeTOSAccepted(exchangeBaseUrl: exchangeBaseUrl, etag: etag) - let response = try await sendRequest(request, ASYNCDELAY) + let response = try await sendRequestM(request, ASYNCDELAY) // TODO: MainActor ? withdrawState = .receivedTOSAck return response - } catch { + } catch { // rethrows withdrawState = .error throw error } } @MainActor - func sendAcceptIntWithdrawal(_ exchangeBaseUrl: String, withdrawURL: String) async throws -> String? { + func sendAcceptIntWithdrawalM(_ exchangeBaseUrl: String, withdrawURL: String) // M for MainActor + async throws -> String? { do { withdrawState = .waitingForWithdrAck let request = AcceptBankIntegratedWithdrawal(talerWithdrawUri: withdrawURL, exchangeBaseUrl: exchangeBaseUrl) - let response = try await sendRequest(request, ASYNCDELAY) + let response = try await sendRequestM(request, ASYNCDELAY) // TODO: MainActor ? withdrawState = .receivedWithdrAck - return response.bankConfirmationUrl - } catch { + return response.confirmTransferUrl + } catch { // rethrows withdrawState = .error throw error } } + @MainActor + func sendAcceptManualWithdrawalM(_ exchangeBaseUrl: String, amount: Amount, restrictAge: Int?) // M for MainActor + async throws -> AcceptManualWithdrawalResult? { + do { + withdrawState = .waitingForWithdrAck + let request = AcceptManualWithdrawal(exchangeBaseUrl: exchangeBaseUrl, amount: amount, restrictAge: restrictAge) + let response = try await sendRequestM(request, ASYNCDELAY) // TODO: MainActor ? + withdrawState = .receivedWithdrAck + return response + } catch { // rethrows + withdrawState = .error + throw error + } + } +} + +// MARK: - +extension WithdrawModel { + private static var exchanges: [String] = [] // names of exchanges + private static var models: [WithdrawModel] = [] // one model per exchange + + static func model(baseURL: String) -> WithdrawModel { + if let index = WithdrawModel.exchanges.firstIndex(of:baseURL) { + let model = WithdrawModel.models[index] + return model + } else { // new model + let model = WithdrawModel() + WithdrawModel.models.append(model) + WithdrawModel.exchanges.append(baseURL) + return model + } + } } diff --git a/TalerWallet1/Preview Content/transactions.json b/TalerWallet1/Preview Content/transactions.json new file mode 100644 index 0000000..b2db39c --- /dev/null +++ b/TalerWallet1/Preview Content/transactions.json @@ -0,0 +1,300 @@ +{
+ "transactions":[
+ { "type": "withdrawal",
+ "amountEffective": "KUDOS:2.9",
+ "amountRaw": "KUDOS:3",
+ "transactionId": "txn:withdrawal:QQWXZ908YYWFPH9QV2GB72Z29C83FFK612WJARXBX6YSNRGPP660",
+ "timestamp": ["t_s": 1683531967]
+ "txState": {
+ "major": "done"
+ },
+ "pending": false,
+
+ "exchangeBaseUrl": "https://exchange.demo.taler.net/",
+ "withdrawalDetails": {
+ "type": "taler-bank-integration-api",
+ "reservePub": "R4G8E58Q97M6HQP69V3J3Q6XX1DXQ06FARWNSNRAWYX446WB2R8G"
+ "bankConfirmationUrl": "https://bank.demo.taler.net/",
+ "confirmed": true,
+ "reserveIsReady": true,
+ },
+ },
+ { "type": "payment",
+ "amountEffective": "KUDOS:1.1",
+ "amountRaw": "KUDOS:1",
+ "transactionId": "txn:payment:43JV9JTB270X1EBH5T59HV9JF2CQW8ZY7M23PS8444BGY4F2Z110",
+ "timestamp": ["t_s": 1683531978],,
+ "txState": {
+ "major": "done"
+ },
+
+ "proposalId": "43JV9JTB270X1EBH5T59HV9JF2CQW8ZY7M23PS8444BGY4F2Z110",
+ "status": "paid",
+ "totalRefundRaw": "KUDOS:0",
+ "totalRefundEffective": "KUDOS:0"
+ "refundQueryActive": false
+ "refunds": [],
+ "info": {
+ "orderId": "2023.128-03A25VEED68QC",
+ "merchant": {
+ "name": "GNU Taler",
+ "jurisdiction": [:],
+ "address": [:]
+ },
+ "summary": "hello world",
+ "products": []
+ "fulfillmentUrl": "taler://fulfillment-success/thx",
+ "contractTermsHash": "X0QJGEPSDKARFPBNTSF5HMXJYDGQ4WRX789PAB2G3EER9J7B5Z8WHHJ0W8KX3Z175A2FGVBKPZ85H8X0Y6H4MNHARJCA9ACW43QS10R",
+ },
+ },
+ { "type": "withdrawal",
+ "amountEffective": "KUDOS:17.8",
+ "amountRaw": "KUDOS:18",
+ "transactionId": "txn:withdrawal:N9CR5EH8BSPB2NVEDW1HAXX94C9DCKSAWF097M4S9AHJ5KW5R01G",
+ "timestamp": ["t_s": 1683531980],
+ "txState": {
+ "major": "done"
+ },
+ "pending": false,
+
+ "exchangeBaseUrl": "https://exchange.demo.taler.net/",
+ "withdrawalDetails": {
+ "type": "taler-bank-integration-api",
+ "reservePub": "6P2Y5N7H1NY8938H60S9WJ1HFS11F90P0RR1H51WCV7MHYYFNS80",
+ "reserveIsReady": true,
+ "confirmed": true,
+ "bankConfirmationUrl": "https://bank.demo.taler.net/",
+ },
+ },
+ { "type": "payment",
+ "amountEffective": "KUDOS:7.2",
+ "amountRaw": "KUDOS:7",
+ "transactionId": "txn:payment:3F9DWB6DNAT8WDPRHQ160AZ1G8WNJH23069Q39HDK8Z3EAR804M0",
+ "timestamp": ["t_s": 1683531996],
+ "txState": {
+ "major": "done"
+ },
+
+ "proposalId": "3F9DWB6DNAT8WDPRHQ160AZ1G8WNJH23069Q39HDK8Z3EAR804M0",
+ "status": "paid",
+ "totalRefundRaw": "KUDOS:0",
+ "totalRefundEffective": "KUDOS:0",
+ "refundPending": "KUDOS:6",
+ "refundQueryActive": false,
+ "refunds": [],
+ "info": {
+ "orderId": "2023.128-0388C075CX1R0",
+ "merchant": {
+ "name": "GNU Taler",
+ "jurisdiction": [:],
+ "address": [:]
+ },
+ "summary": "order that will be refunded",
+ "products": [],
+ "fulfillmentUrl": "taler://fulfillment-success/thx",
+ "contractTermsHash": "SPD5XJTFE8N73FXQT0JRVZ2ABSGKWVHFWV1GAZGDAWZT7CK0WSHCJSWV1FMCFEGTHT786ZPFVFHJWWB3V0ADCNPBT0YFS78Z2P3KYH0",
+ },
+ },
+ { "type": "refund",
+ "amountEffective": "KUDOS:5.8",
+ "amountRaw": "KUDOS:6",
+ "transactionId": "txn:refund:PFW6JNX6QVKMF5HHER8KHPS9ADJTPXBQ1JK6C7W55F4X6CK59Q20",
+ "timestamp": {"t_s": 1683531997},
+ "txState": {
+ "major": "done"
+ },
+
+ "refundedTransactionId": "txn:payment:3F9DWB6DNAT8WDPRHQ160AZ1G8WNJH23069Q39HDK8Z3EAR804M0",
+ },
+ { "type": "payment",
+ "amountEffective": "KUDOS:3.2",
+ "amountRaw": "KUDOS:3",
+ "transactionId": "txn:payment:8AG5GVQ7E2FGREEH8V5ADNW1D6NPVD4YCF26763A9JYEQ76AVBX0",
+ "timestamp": ["t_s": 1683531998],
+ "txState": {
+ "major": "done"
+ },
+
+ "proposalId": "8AG5GVQ7E2FGREEH8V5ADNW1D6NPVD4YCF26763A9JYEQ76AVBX0"
+ "status": "paid",
+ "totalRefundRaw": "KUDOS:0",
+ "totalRefundEffective": "KUDOS:0",
+ "refundQueryActive": false,
+ "refunds": [],
+ "info": {
+ "orderId": "2023.128-00G672RZHTRWA",
+ "merchant": [
+ "name": "GNU Taler",
+ "address": [:],
+ "jurisdiction": [:]
+ ],
+ "summary": "payment after refund",
+ "products": []
+ "fulfillmentUrl": "taler://fulfillment-success/thx",
+ "contractTermsHash": "GZZ75P8G5H7PEV93E6M7TNNT1RNCSP0MJT7K7K4VXJAZGK5FAFC2YJYDGX3CT077VPQFZJR3QV5M9ZNH2T86ZWBA8BDDREM12RZYWN0",
+ },
+ }
+ ]
+}
+
+
+"transactions":[
+ {
+ "type":"withdrawal",
+ "txState":{
+ "major":"done"
+ },
+ "amountEffective":"KUDOS:2.9",
+ "amountRaw":"KUDOS:3",
+ "withdrawalDetails":{
+ "type":"taler-bank-integration-api",
+ "confirmed":true,
+ "reservePub":"R4G8E58Q97M6HQP69V3J3Q6XX1DXQ06FARWNSNRAWYX446WB2R8G",
+ "bankConfirmationUrl":"https://bank.demo.taler.net/",
+ "reserveIsReady":true
+ },
+ "exchangeBaseUrl":"https://exchange.demo.taler.net/",
+ "pending":false,
+ "timestamp":{ "t_s":1683531967 },
+ "transactionId":"txn:withdrawal:QQWXZ908YYWFPH9QV2GB72Z29C83FFK612WJARXBX6YSNRGPP660",
+ },
+ {
+ "type":"payment",
+ "txState":{
+ "major":"done"
+ },
+ "amountRaw":"KUDOS:1",
+ "amountEffective":"KUDOS:1.1",
+ "totalRefundRaw":"KUDOS:0",
+ "totalRefundEffective":"KUDOS:0",
+ "status":"paid",
+ "extendedStatus":"done",
+ "pending":false,
+ "refunds":[],
+ "timestamp":{
+ "t_s":1683531978
+ },
+ "transactionId":"txn:payment:43JV9JTB270X1EBH5T59HV9JF2CQW8ZY7M23PS8444BGY4F2Z110",
+ "proposalId":"43JV9JTB270X1EBH5T59HV9JF2CQW8ZY7M23PS8444BGY4F2Z110",
+ "info":{
+ "merchant":{
+ "name":"GNU Taler",
+ "address":{
+
+ },
+ "jurisdiction":{
+ }
+ },
+ "orderId":"2023.128-03A25VEED68QC",
+ "products":[],
+ "summary":"hello world",
+ "contractTermsHash":"X0QJGEPSDKARFPBNTSF5HMXJYDGQ4WRX789PAB2G3EER9J7B5Z8WHHJ0W8KX3Z175A2FGVBKPZ85H8X0Y6H4MNHARJCA9ACW43QS10R",
+ "fulfillmentUrl":"taler://fulfillment-success/thx"
+ },
+ "refundQueryActive":false,
+ },
+ {
+ "type":"withdrawal",
+ "txState":{
+ "major":"done"
+ },
+ "amountEffective":"KUDOS:17.8",
+ "amountRaw":"KUDOS:18",
+ "withdrawalDetails":{
+ "type":"taler-bank-integration-api",
+ "confirmed":true,
+ "reservePub":"6P2Y5N7H1NY8938H60S9WJ1HFS11F90P0RR1H51WCV7MHYYFNS80",
+ "bankConfirmationUrl":"https://bank.demo.taler.net/",
+ "reserveIsReady":true
+ },
+ "exchangeBaseUrl":"https://exchange.demo.taler.net/",
+ "extendedStatus":"done",
+ "pending":false,
+ "timestamp":{ "t_s":1683531980 },
+ "transactionId":"txn:withdrawal:N9CR5EH8BSPB2NVEDW1HAXX94C9DCKSAWF097M4S9AHJ5KW5R01G",
+ },
+ {
+ "type":"payment",
+ "txState":{
+ "major":"done"
+ },
+ "amountRaw":"KUDOS:7",
+ "amountEffective":"KUDOS:7.2",
+ "totalRefundRaw":"KUDOS:0",
+ "totalRefundEffective":"KUDOS:0",
+ "refundPending":"KUDOS:6",
+ "status":"paid",
+ "extendedStatus":"done",
+ "pending":false,
+ "refunds":[],
+ "timestamp":{ "t_s":1683531996 },
+ "transactionId":"txn:payment:3F9DWB6DNAT8WDPRHQ160AZ1G8WNJH23069Q39HDK8Z3EAR804M0",
+ "proposalId":"3F9DWB6DNAT8WDPRHQ160AZ1G8WNJH23069Q39HDK8Z3EAR804M0",
+ "info":{
+ "merchant":{
+ "name":"GNU Taler",
+ "address":{
+
+ },
+ "jurisdiction":{
+ }
+ },
+ "orderId":"2023.128-0388C075CX1R0",
+ "products":[],
+ "summary":"order that will be refunded",
+ "contractTermsHash":"SPD5XJTFE8N73FXQT0JRVZ2ABSGKWVHFWV1GAZGDAWZT7CK0WSHCJSWV1FMCFEGTHT786ZPFVFHJWWB3V0ADCNPBT0YFS78Z2P3KYH0",
+ "fulfillmentUrl":"taler://fulfillment-success/thx"
+ },
+ "refundQueryActive":false,
+ },
+ {
+ "type":"refund",
+ "amountEffective":"KUDOS:5.8",
+ "amountRaw":"KUDOS:6",
+ "refundedTransactionId":"txn:payment:3F9DWB6DNAT8WDPRHQ160AZ1G8WNJH23069Q39HDK8Z3EAR804M0",
+ "timestamp":{
+ "t_s":1683531997
+ },
+ "transactionId":"txn:refund:PFW6JNX6QVKMF5HHER8KHPS9ADJTPXBQ1JK6C7W55F4X6CK59Q20",
+ "txState":{
+ "major":"done"
+ },
+ "extendedStatus":"done",
+ "pending":false
+ },
+ {
+ "type":"payment",
+ "txState":{
+ "major":"done"
+ },
+ "amountRaw":"KUDOS:3",
+ "amountEffective":"KUDOS:3.2",
+ "totalRefundRaw":"KUDOS:0",
+ "totalRefundEffective":"KUDOS:0",
+ "status":"paid",
+ "extendedStatus":"done",
+ "pending":false,
+ "refunds":[],
+ "timestamp":{
+ "t_s":1683531998
+ },
+ "transactionId":"txn:payment:8AG5GVQ7E2FGREEH8V5ADNW1D6NPVD4YCF26763A9JYEQ76AVBX0",
+ "proposalId":"8AG5GVQ7E2FGREEH8V5ADNW1D6NPVD4YCF26763A9JYEQ76AVBX0",
+ "info":{
+ "merchant":{
+ "name":"GNU Taler",
+ "address":{
+
+ },
+ "jurisdiction":{
+ }
+ },
+ "orderId":"2023.128-00G672RZHTRWA",
+ "products":[],
+ "summary":"payment after refund",
+ "contractTermsHash":"GZZ75P8G5H7PEV93E6M7TNNT1RNCSP0MJT7K7K4VXJAZGK5FAFC2YJYDGX3CT077VPQFZJR3QV5M9ZNH2T86ZWBA8BDDREM12RZYWN0",
+ "fulfillmentUrl":"taler://fulfillment-success/thx"
+ },
+ "refundQueryActive":false,
+ }
+]
diff --git a/TalerWallet1/Quickjs/quickjs.swift b/TalerWallet1/Quickjs/quickjs.swift index 040bfcb..b301190 100644 --- a/TalerWallet1/Quickjs/quickjs.swift +++ b/TalerWallet1/Quickjs/quickjs.swift @@ -1,17 +1,6 @@ /* - * 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/> + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md */ import Foundation diff --git a/TalerWallet1/Settings.bundle/Root.plist b/TalerWallet1/Settings.bundle/Root.plist new file mode 100644 index 0000000..89aaf6b --- /dev/null +++ b/TalerWallet1/Settings.bundle/Root.plist @@ -0,0 +1,21 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> +<plist version="1.0"> +<dict> + <key>StringsTable</key> + <string>Root</string> + <key>PreferenceSpecifiers</key> + <array> + <dict> + <key>Type</key> + <string>PSToggleSwitchSpecifier</string> + <key>Title</key> + <string>Diagnostic Mode</string> + <key>Key</key> + <string>diagnostic_mode_enabled</string> + <key>DefaultValue</key> + <false/> + </dict> + </array> +</dict> +</plist> diff --git a/TalerWallet1/Settings.bundle/en.lproj/Root.strings b/TalerWallet1/Settings.bundle/en.lproj/Root.strings Binary files differnew file mode 100644 index 0000000..8cd87b9 --- /dev/null +++ b/TalerWallet1/Settings.bundle/en.lproj/Root.strings diff --git a/TalerWallet1/Views/Balances/BalanceRow.swift b/TalerWallet1/Views/Balances/BalanceRow.swift deleted file mode 100644 index 9c8aeee..0000000 --- a/TalerWallet1/Views/Balances/BalanceRow.swift +++ /dev/null @@ -1,46 +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 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/BalanceRowView.swift b/TalerWallet1/Views/Balances/BalanceRowView.swift new file mode 100644 index 0000000..cb847ec --- /dev/null +++ b/TalerWallet1/Views/Balances/BalanceRowView.swift @@ -0,0 +1,52 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +import SwiftUI +import taler_swift + +/// This view shows the currency row in a currency section +/// [Send Coins] [Receive Coins] Balance + +struct BalanceRowView: View { + let amount: Amount + let sendAction: () -> Void + let recvAction: () -> Void + let rowAction: () -> Void + var body: some View { + HStack { + Button("Request\nPayment", action: recvAction) + .lineLimit(2) + .disabled(false) + .buttonStyle(TalerButtonStyle(type: .bordered, narrow: true, aligned: .leading)) + Button("Send\nCoins", action: sendAction) + .lineLimit(2) + .disabled(amount.isZero) + .buttonStyle(TalerButtonStyle(type: .bordered, narrow: true, aligned: .leading)) + Button(action: rowAction) { + VStack(alignment: .trailing) { + Text("Balance") + .font(.footnote) + .accessibility(sortPriority: 2) + Text("\(amount.valueStr)") // TODO: CurrencyFormatter + .font(.title) + .accessibility(sortPriority: 1) + } + } .disabled(false) + .buttonStyle(TalerButtonStyle(type: .plain, aligned: .trailing)) + } + .fixedSize(horizontal: false, vertical: true) // should make all buttons equal height - but doesn't + .accessibilityElement(children: .combine) + } +} +// MARK: - +#if DEBUG +struct BalanceRowView_Previews: PreviewProvider { + static var previews: some View { + List { + BalanceRowView(amount: try! Amount(fromString: LONGCURRENCY + ":0.1"), + sendAction: {}, recvAction: {}, rowAction: {}) + } + } +} +#endif diff --git a/TalerWallet1/Views/Balances/BalancesListView.swift b/TalerWallet1/Views/Balances/BalancesListView.swift new file mode 100644 index 0000000..15d0f1c --- /dev/null +++ b/TalerWallet1/Views/Balances/BalancesListView.swift @@ -0,0 +1,133 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +import SwiftUI +import taler_swift +import SymLog +import AVFoundation + +/// This view shows the list of balances / currencies, each in its own section + +struct BalancesListView: View { + private let symLog = SymLogV() + let navTitle = String(localized: "GNU Taler") // + Wallet + @AppStorage("listStyle") var myListStyle = MyListStyle.automatic + + @ObservedObject var model: BalancesModel + var hamburgerAction: () -> Void + + @State private var centsToTransfer: UInt64 = 0 // TODO: maybe Decimal? + @State private var showQRScanner: Bool = false + @State private var showCameraAlert: Bool = false + + var ClosingAnnouncement = AttributedString(localized: "Closing Camera") + private var openSettingsButton: some View { + Button("Open Settings") { + showCameraAlert = false + UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) + } + } + private var dismissAlertButton: some View { + Button("Cancel", role: .cancel) { +// AccessibilityNotification.Announcement(ClosingAnnouncement).post() + showCameraAlert = false + } + } + + var defaultPriorityAnnouncement = AttributedString(localized: "Opening Camera") + var lowPriorityAnnouncement: AttributedString { + var lowPriorityString = AttributedString ("Camera Loading") + // lowPriorityString.accessibilitySpeechAnnouncementPriority = .low + return lowPriorityString + } + var highPriorityAnnouncement: AttributedString { + var highPriorityString = AttributedString("Camera Active") + // highPriorityString.accessibilitySpeechAnnouncementPriority = .high + return highPriorityString + } + private func checkCameraAvailable() -> Void { + /// Open Camera Code +// AccessibilityNotification.Announcement(defaultPriorityAnnouncement).post() + AVCaptureDevice.requestAccess(for: .video, completionHandler: { (granted: Bool) -> Void in + if granted { + showQRScanner = true + } else { + // Camera Loaded Code +// AccessibilityNotification.Announcement(highPriorityAnnouncement).post() + showCameraAlert = true + } + }) + } + + var body: some View { +#if DEBUG + let _ = Self._printChanges() + let _ = symLog.vlog() // just to get the # to compare it with .onAppear & onDisappear +#endif + let reloadAction = model.fetchBalancesM + Content(symLog: symLog, model: model, centsToTransfer: $centsToTransfer, + reloadAction: reloadAction, myListStyle: $myListStyle) + .navigationTitle(navTitle) + .navigationBarItems(leading: HamburgerButton(action: hamburgerAction), + trailing: QRButton(action: checkCameraAvailable)) + .alert("Scanning QR-codes requires access to the camera", + isPresented: $showCameraAlert, + actions: { openSettingsButton + dismissAlertButton }, + message: { Text("Please allow camera access in settings.") }) + .sheet(isPresented: $showQRScanner) { + let sheet = AnyView(QRSheet()) + Sheet(sheetView: sheet) + } // sheet + .task { + symLog.log(".task fetchBalancesM") + await reloadAction() + } // task + } +} +// MARK: - +extension BalancesListView { + struct Content: View { + let symLog: SymLogV? + @ObservedObject var model: BalancesModel + @Binding var centsToTransfer: UInt64 + var reloadAction: () async -> () + @Binding var myListStyle: MyListStyle + + var body: some View { +#if DEBUG + let _ = Self._printChanges() + let _ = symLog?.vlog() // just to get the # to compare it with .onAppear & onDisappear +#endif + Group { + List (model.balances, id: \.self) { balance in + let model = TransactionsModel.model(currency: balance.available.currencyStr) + BalancesSectionView(balance: balance, centsToTransfer: $centsToTransfer, model: model) + } + .refreshable { + symLog?.log("refreshing") + await reloadAction() // this closure is already async, no need for a Task + } + .listStyle(myListStyle.style) + .anyView + } + .navigationBarTitleDisplayMode(.automatic) + .onAppear() { + DebugViewC.shared.setViewID(VIEW_BALANCES) + } + .overlay { + if model.balances.isEmpty { + WalletEmptyView() + .background(WalletColors().backgroundColor.edgesIgnoringSafeArea(.all)) + } + } + // automatically fetch balances after receiving transaction-state-transition ... + .onNotification(.TransactionStateTransition) { notification in + // doesn't need to be received on main thread because we just reload in the background anyway + symLog?.log(".onNotification(.TransactionStateTransition) ==> reloading balances") + Task { await reloadAction() } + } + } // body + } // Content +} diff --git a/TalerWallet1/Views/Balances/BalancesSectionView.swift b/TalerWallet1/Views/Balances/BalancesSectionView.swift new file mode 100644 index 0000000..d9535c1 --- /dev/null +++ b/TalerWallet1/Views/Balances/BalancesSectionView.swift @@ -0,0 +1,161 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +import SwiftUI +import taler_swift +import SymLog + +/// This view shows a currency section +/// Currency Name +/// [Send Coins] [Receive Coins] Balance +/// tapping on Balance leads to completed Transactions (.done) +/// optional: Pending Incoming +/// optional: Pending Outgoing +/// optional: Suspended / Aborting / Aborted / Expired + +struct BalancesSectionView: View { + private let symLog = SymLogV() + var balance:Balance + @Binding var centsToTransfer: UInt64 + @ObservedObject var model: TransactionsModel + + @State private var isShowingDetailView = false + @State private var buttonSelected: Int? = nil + @State private var completedTransactions: [Transaction] = [] + @State private var pendingTransactions: [Transaction] = [] + @State private var uncompletedTransactions: [Transaction] = [] + + var body: some View { + let currency = balance.available.currencyStr + let reloadCompleted = { + await model.fetchTransactions(currency: currency) + completedTransactions = TransactionsModel.completedTransactions(model.transactions) + } + let reloadPending = { + await model.fetchTransactions(currency: currency) + pendingTransactions = TransactionsModel.pendingTransactions(model.transactions) + } + let reloadUncompleted = { + await model.fetchTransactions(currency: currency) + uncompletedTransactions = TransactionsModel.uncompletedTransactions(model.transactions) + } + let deleteAction = model.deleteTransaction + let abortAction = model.abortTransaction + + + Section { + if "KUDOS" == currency && !balance.available.isZero { + Text("You can spend these KUDOS in the [Demo Shop](https://shop.demo.taler.net), or send coins to another wallet.") + .multilineTextAlignment(.leading) + } + HStack(spacing: 0) { + NavigationLink(destination: LazyView { + SendAmount(amountAvailable: balance.available) + }, tag: 1, selection: $buttonSelected + ) { EmptyView() }.frame(width: 0).opacity(0).hidden() + + NavigationLink(destination: LazyView { + RequestPayment(model: Peer2peerModel.model(), + scopeInfo: balance.scopeInfo, + centsToTransfer: $centsToTransfer) + .background(WalletColors().backgroundColor.edgesIgnoringSafeArea(.all)) + }, tag: 2, selection: $buttonSelected + ) { EmptyView() }.frame(width: 0).opacity(0).hidden() + + NavigationLink(destination: LazyView { + TransactionsListView(navTitle: String(localized: "Transactions"), currency: currency, + transactions: completedTransactions, + reloadAction: reloadCompleted, + deleteAction: deleteAction, + abortAction: abortAction) + }, tag: 3, selection: $buttonSelected + ) { EmptyView() }.frame(width: 0).opacity(0).hidden() + + BalanceRowView(amount: balance.available, sendAction: { +print("button: Send Coins: \(currency)") + buttonSelected = 1 // will trigger SendAmount NavigationLink + }, recvAction: { +print("button: Request Payment: \(currency)") + buttonSelected = 2 // will trigger RequestPayment NavigationLink + }, rowAction: { +print("button: Transactions: \(currency)") + buttonSelected = 3 // will trigger TransactionList NavigationLink + }) + } + let hasPending = pendingTransactions.count > 0 + if hasPending { + let hasPendingIn = !balance.pendingIncoming.isZero + let hasPendingOut = !balance.pendingOutgoing.isZero + NavigationLink { +let _ = print("button: Pending Transactions: \(currency)") + LazyView { + TransactionsListView(navTitle: String(localized: "Pending"), currency: currency, + transactions: pendingTransactions, + reloadAction: reloadPending, + deleteAction: deleteAction, + abortAction: abortAction) + } + } label: { + VStack(spacing: 6) { + if hasPendingIn { + PendingRowView(amount: balance.pendingIncoming, incoming: true) + } + if hasPendingOut { + PendingRowView(amount: balance.pendingOutgoing, incoming: false) + } + } + } + } + let hasUncompleted = uncompletedTransactions.count > 0 + if hasUncompleted { + NavigationLink { +let _ = print("button: Uncompleted Transactions: \(currency)") + LazyView { + TransactionsListView(navTitle: String(localized: "Uncompleted"), currency: currency, + transactions: uncompletedTransactions, + reloadAction: reloadUncompleted, + deleteAction: deleteAction, + abortAction: abortAction) + } + } label: { + UncompletedRowView(uncompletedTransactions: uncompletedTransactions) + } + + } + } header: { + Text(currency) + .font(.title) + } .task { + await model.fetchTransactions(currency: currency) + pendingTransactions = TransactionsModel.pendingTransactions(model.transactions) + uncompletedTransactions = TransactionsModel.uncompletedTransactions(model.transactions) + } + } // body +} +// MARK: - +#if DEBUG +fileprivate struct BindingViewContainer : View { + @State var centsToTransfer: UInt64 = 333 + let model = TransactionsModel.model(currency: LONGCURRENCY) + + var body: some View { + let scopeInfo = ScopeInfo(type: ScopeInfo.ScopeInfoType.exchange, exchangeBaseUrl: DEMOEXCHANGE, currency: LONGCURRENCY) + let balance = Balance(available: try! Amount(fromString: LONGCURRENCY + ":0.1"), + pendingIncoming: try! Amount(fromString: LONGCURRENCY + ":4.8"), + pendingOutgoing: try! Amount(fromString: LONGCURRENCY + ":3.25"), + hasPendingTransactions: true, + requiresUserInput: false, + scopeInfo: scopeInfo) + List { + BalancesSectionView(balance: balance, centsToTransfer: $centsToTransfer, model: model) + } + } +} + +struct BalancesSectionView_Previews: PreviewProvider { + static var previews: some View { + BindingViewContainer() + } +} +#endif diff --git a/TalerWallet1/Views/Balances/CurrenciesListView.swift b/TalerWallet1/Views/Balances/CurrenciesListView.swift deleted file mode 100644 index 192d531..0000000 --- a/TalerWallet1/Views/Balances/CurrenciesListView.swift +++ /dev/null @@ -1,83 +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 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 { - do { - symLog?.log("refreshing") - try await reloadAction() - } catch { - // TODO: error - symLog?.log(error.localizedDescription) - } - } - } - } - } -} diff --git a/TalerWallet1/Views/Balances/CurrencyView.swift b/TalerWallet1/Views/Balances/CurrencyView.swift deleted file mode 100644 index 0978de0..0000000 --- a/TalerWallet1/Views/Balances/CurrencyView.swift +++ /dev/null @@ -1,58 +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 - -/// 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 deleted file mode 100644 index d6321d2..0000000 --- a/TalerWallet1/Views/Balances/PendingRow.swift +++ /dev/null @@ -1,61 +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 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/PendingRowView.swift b/TalerWallet1/Views/Balances/PendingRowView.swift new file mode 100644 index 0000000..95345f0 --- /dev/null +++ b/TalerWallet1/Views/Balances/PendingRowView.swift @@ -0,0 +1,48 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +import SwiftUI +import taler_swift + +/// This view shows a pending transaction row in a currency section +struct PendingRowView: View { + let amount: Amount + let incoming: Bool + + var body: some View { + HStack { + Image(systemName: incoming ? "text.badge.plus" + : "text.badge.minus") + .font(.largeTitle) +// .foregroundColor(WalletColors().pendingColor) // pending is always gray + .foregroundColor(WalletColors().pendingColor(incoming)) + .accessibility(hidden: true) + + Spacer() + Text("pending\n" + (incoming ? "incoming" : "outgoing")) + Spacer() + VStack(alignment: .trailing) { + let sign = incoming ? "+" : "-" + Text(sign + "\(amount.valueStr)") + .font(.title) + .foregroundColor(WalletColors().pendingColor(incoming)) +// Text("PENDING") +// .font(.callout) +// .foregroundColor(WalletColors().pendingColor(incoming)) + } + } + .accessibilityElement(children: .combine) + } +} +// MARK: - +#if DEBUG +struct PendingRowView_Previews: PreviewProvider { + static var previews: some View { + List { + PendingRowView(amount: try! Amount(fromString: LONGCURRENCY + ":4.8"), incoming: true) + PendingRowView(amount: try! Amount(fromString: LONGCURRENCY + ":3.25"), incoming: false) + } + } +} +#endif diff --git a/TalerWallet1/Views/Balances/UncompletedRowView.swift b/TalerWallet1/Views/Balances/UncompletedRowView.swift new file mode 100644 index 0000000..b7642dc --- /dev/null +++ b/TalerWallet1/Views/Balances/UncompletedRowView.swift @@ -0,0 +1,34 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +import SwiftUI +import taler_swift + +/// This view shows an uncompleted transaction row in a currency section +struct UncompletedRowView: View { + var uncompletedTransactions: [Transaction] + + var body: some View { + let count = uncompletedTransactions.count + HStack { + Spacer() + Text("\(count) uncompleted transactions") + .font(.title2) + .foregroundColor(WalletColors().uncompletedColor) + Spacer() + } + .accessibilityElement(children: .combine) + } +} +// MARK: - +#if DEBUG +struct UncompletedRowView_Previews: PreviewProvider { + static var previews: some View { + let uncompletedTransactions: [Transaction] = [] + List { + UncompletedRowView(uncompletedTransactions: uncompletedTransactions) + } + } +} +#endif diff --git a/TalerWallet1/Views/Balances/WalletEmptyView.swift b/TalerWallet1/Views/Balances/WalletEmptyView.swift deleted file mode 100644 index 72d5192..0000000 --- a/TalerWallet1/Views/Balances/WalletEmptyView.swift +++ /dev/null @@ -1,44 +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 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 index fbd52e4..e7fd4fe 100644 --- a/TalerWallet1/Views/Exchange/ExchangeListView.swift +++ b/TalerWallet1/Views/Exchange/ExchangeListView.swift @@ -1,103 +1,129 @@ /* - * 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/> + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md */ import SwiftUI +import taler_swift import SymLog +/// This view shows the list of exchanges struct ExchangeListView: View { private let symLog = SymLogV() - let navTitle = "Exchanges" + let navTitle = String(localized: "Exchanges") - @ObservedObject var viewModel: ExchangeModel + @ObservedObject var model: ExchangeModel + var hamburgerAction: () -> Void + + + // source of truth for the value the user enters in currencyField + @State private var centsToTransfer: UInt64 = 0 // TODO: different values for different currencies 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) +#if DEBUG + let _ = Self._printChanges() + let _ = symLog.vlog() // just to get the # to compare it with .onAppear & onDisappear +#endif + let reloadAction = model.updateListM + Content(symLog: symLog, model: model, + centsToTransfer: $centsToTransfer, + 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: - +struct ExchangeAmount: Identifiable { + let exchange: Exchange + let amountAvailable: Amount + + var id: String { // needed for Identifiable + exchange.exchangeBaseUrl } } // MARK: - extension ExchangeListView { struct Content: View { - let symLog: SymLogV - @ObservedObject var viewModel: ExchangeModel + let symLog: SymLogV? + @AppStorage("listStyle") var myListStyle = MyListStyle.automatic + @ObservedObject var model: ExchangeModel + @Binding var centsToTransfer: UInt64 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 + symLog?.log("adding: \(exUrl)") + try await model.add(url: exUrl) + symLog?.log("added: \(exUrl)") + } catch { // TODO: error handling - couldn't add exchangeURL + symLog?.log("error: \(error)") } } } + func currenciesDict(_ exchanges: [Exchange]) -> [String : [Exchange]] { + var currencies: [String : [Exchange]] = [:] + + for exchange in exchanges { + let currency = exchange.currency ?? "Unknown" + if currencies[currency] != nil { + currencies[currency]!.append(exchange) + } else { + currencies[currency] = [exchange] + } + } + return currencies + } + + @State private var exchangeAmount: ExchangeAmount? = nil + var body: some View { let plusAction: () -> Void = { -// withAnimation { showPopup = true } +// withAnimation { showAlert = true } showAlert = true } VStack { - if viewModel.exchanges!.isEmpty { + if model.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() + ScrollViewReader { scrollView in // + let dict = currenciesDict(model.exchanges) + let sortedDict = dict.sorted{ $0.key < $1.key} + List { + ForEach(sortedDict, id: \.key) { key, value in + ExchangeSectionView(currency: key, exchanges: value, centsToTransfer: $centsToTransfer) + } } - } - .navigationBarTitleDisplayMode(.large) // .inline .refreshable { do { - symLog.log("refreshing") + symLog?.log("refreshing") try await reloadAction() - } catch { - // TODO: error - symLog.log(error.localizedDescription) + } catch { // TODO: error + symLog?.log(error.localizedDescription) } } - } + .listStyle(myListStyle.style) + .anyView + } + } // else + }.onAppear() { + DebugViewC.shared.setViewID(VIEW_EXCHANGES) } - .navigationBarItems(trailing: PlusButton(action: plusAction)) - .textFieldAlert(isPresented: $showAlert, title: "Add Exchange", - doneText: "Add", text: $newExchange, action: addExchange) + .navigationBarTitleDisplayMode(.automatic) + .navigationBarItems(trailing: PlusButton(action: plusAction)) + .textFieldAlert(isPresented: $showAlert, title: "Add Exchange", + doneText: "Add", text: $newExchange, action: addExchange) } // body } } diff --git a/TalerWallet1/Views/Exchange/ExchangeSectionView.swift b/TalerWallet1/Views/Exchange/ExchangeSectionView.swift new file mode 100644 index 0000000..cdcacbe --- /dev/null +++ b/TalerWallet1/Views/Exchange/ExchangeSectionView.swift @@ -0,0 +1,112 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +import SwiftUI +import taler_swift + +struct ExchangeRowView: View { + let exchange: Exchange + @Binding var centsToTransfer: UInt64 + + @State private var buttonSelected: Int? = nil + var body: some View { + let baseURL = exchange.exchangeBaseUrl + let model = WithdrawModel.model(baseURL: baseURL) + + HStack(spacing: 0) { // can't use the built in Label because it adds the accessory arrow + Text(baseURL.trimURL()) + + NavigationLink(destination: LazyView { + EmptyView() + }, tag: 1, selection: $buttonSelected + ) { + EmptyView() + } .frame(width: 0) + .opacity(0) + NavigationLink(destination: LazyView { + ManualWithdraw(exchange: exchange, + model: model, + centsToTransfer: $centsToTransfer) + .background(WalletColors().backgroundColor.edgesIgnoringSafeArea(.all)) + }, tag: 2, selection: $buttonSelected + ) { + EmptyView() + } .frame(width: 0) + .opacity(0) + }.listRowSeparator(.hidden) + + HStack { // buttons just set "buttonSelected" so the NavigationLink will trigger + Button("Deposit\nCoins") { buttonSelected = 1 } + .multilineTextAlignment(.center) + .lineLimit(2) + .buttonStyle(TalerButtonStyle(type: .bordered)) + .disabled(true) // TODO: after implementing Deposit check available + + Button("Withdraw\nCoins") { buttonSelected = 2 } + .multilineTextAlignment(.center) + .lineLimit(2) + .buttonStyle(TalerButtonStyle(type: .bordered)) + }.listRowSeparator(.visible) +// .listRowSeparatorTint(.red) + .fixedSize(horizontal: false, vertical: true) + } +} +/// This view shows the currency name in an exchange section +/// currency +/// [Deposit Coins] [Withdraw Coins] +struct ExchangeSectionView: View { + let currency: String + let exchanges: [Exchange] + + @Binding var centsToTransfer: UInt64 + + var body: some View { +#if DEBUG + let _ = Self._printChanges() +// let _ = symLog.vlog() // just to get the # to compare it with .onAppear & onDisappear +#endif + Section { + ForEach(exchanges) { exchange in + ExchangeRowView(exchange: exchange, centsToTransfer: $centsToTransfer) + } + .accessibilityElement(children: .combine) + } header: { + Text(currency) + .font(.title) + } + } +} +// MARK: - +#if DEBUG +struct ExchangeRow_Container : View { + @State private var centsToTransfer: UInt64 = 100 // TODO: maybe Decimal? + + var body: some View { + let exchange1 = Exchange(exchangeBaseUrl: DEMO_AGE_EXCHANGE, + currency: LONGCURRENCY, + paytoUris: [], + tosStatus: "tosStatus", + exchangeStatus: "exchangeStatus", + ageRestrictionOptions: [12,16], + permanent: true) + let exchange2 = Exchange(exchangeBaseUrl: DEMO_EXP_EXCHANGE, + currency: LONGCURRENCY, + paytoUris: [], + tosStatus: "tosStatus", + exchangeStatus: "exchangeStatus", + ageRestrictionOptions: [], + permanent: false) + List { + ExchangeSectionView(currency: LONGCURRENCY, exchanges: [exchange1, exchange2], + centsToTransfer: $centsToTransfer) + } + } +} + +struct ExchangeRow_Previews: PreviewProvider { + static var previews: some View { + ExchangeRow_Container() + } +} +#endif diff --git a/TalerWallet1/Views/Exchange/ManualWithdraw.swift b/TalerWallet1/Views/Exchange/ManualWithdraw.swift new file mode 100644 index 0000000..db32716 --- /dev/null +++ b/TalerWallet1/Views/Exchange/ManualWithdraw.swift @@ -0,0 +1,199 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +import SwiftUI +import taler_swift +import SymLog + +struct ManualWithdraw: View { + private let symLog = SymLogV() + let navTitle = String(localized: "Withdraw Coins") + + var exchange: Exchange + @ObservedObject var model: WithdrawModel + @Binding var centsToTransfer: UInt64 + + @State var manualWithdrawalDetails: ManualWithdrawalDetails? = nil +// @State var numCoins: Int = 0 + + // returns numCoins, 0 if invalid, -1 if unknown + // either fees or empty string + private func numAndFee(detailsForAmount: ManualWithdrawalDetails?) -> (Int, String) { + do { + if let details = detailsForAmount { + let fee = try details.amountRaw - details.amountEffective + return (details.numCoins ?? -1, // either the number of coins, or unknown + fee.isZero ? "" : fee.readableDescription) + } + } catch {} + symLog.log("invalid") + return (0, "") // invalid + } + @State var ageMenuList: [Int] = [] + @State var selectedAge = 0 + + + var body: some View { +#if DEBUG + let _ = Self._printChanges() + let _ = symLog.vlog() // just to get the # to compare it with .onAppear & onDisappear +#endif + let currency = exchange.currency! + let currencyField = CurrencyField(value: $centsToTransfer, currency: currency) // becomeFirstResponder + + ScrollView { + Text("from \(exchange.exchangeBaseUrl.trimURL())") + .padding(.top) + .font(.title3) + CurrencyInputView(currencyField: currencyField, title: String(localized: "Amount to withdraw:")) + + let (numCoins, fee) = numAndFee(detailsForAmount: manualWithdrawalDetails) + let unknown = (numCoins < 0) + let invalid = (numCoins == 0) + let manyCoins = (numCoins > 99) + let quiteSome = (numCoins > 199) + let tooMany = (numCoins > 999) + let hasFee = (fee.count > 0) + let shownFee = hasFee ? String(localized: "- \(fee)") : String(localized: "No") + Text(invalid ? "invalid amount" + : tooMany ? "too many coins for a single withdrawal" + : "\(shownFee) withdrawal fee") + .foregroundColor((invalid || tooMany || hasFee) ? .red : .primary) + .padding() + + if !invalid { + HStack { + Text(unknown ? "Some" : "\(numCoins)") + .foregroundColor(quiteSome ? .red : .primary) + Text(tooMany ? "coins" : "coins to obtain:") + .foregroundColor(tooMany ? .red : .primary) + + Spacer() + if !tooMany { + let effective = manualWithdrawalDetails?.amountEffective ?? Amount(currency: currency, value: 0) + Text(effective.readableDescription) + } + } // xx coins to obtain: YYY currency + .padding(.top) +// .font(.title3) + + if !tooMany { + if manyCoins { + Text(quiteSome ? "Warning: It will take quite some time\nto generate this many coins!" + : "Warning: It will take some time\nto generate this many coins.") + .multilineTextAlignment(.leading) + .padding(.top, 6) + .foregroundColor(quiteSome ? .red : .primary) + } // warnings + + if ageMenuList.count > 1 { + HStack { + Text("If this wallet belongs to a child or teenager, the generated coins should be age-restricted:") + .multilineTextAlignment(.leading) + .font(.footnote) + Spacer() + }.padding(.top) + Picker("Select age", selection: $selectedAge) { + ForEach($ageMenuList, id: \.self) { item in + let index = item.wrappedValue + Text((index == 0) ? "unlimited" + : "\(index) years").tag(index) + } + } + } + if let tosAcc = manualWithdrawalDetails?.tosAccepted { + if tosAcc { + let restrictAge: Int? = (selectedAge == 0) ? nil + : selectedAge +let _ = print(selectedAge, restrictAge) + NavigationLink(destination: LazyView { + ManualWithdrawDone(exchange: exchange, + model: model, + centsToTransfer: centsToTransfer, + restrictAge: restrictAge) + }) { + Text("Confirm Withdrawal") // VIEW_WITHDRAW_ACCEPT + }.buttonStyle(TalerButtonStyle(type: .prominent)) + } else { + NavigationLink(destination: LazyView { + WithdrawTOSView(exchangeBaseUrl: exchange.exchangeBaseUrl, + model: model, + viewID: VIEW_WITHDRAW_TOS, + acceptAction: nil) // pop back to here + }) { + Text("Check Terms of Service") // VIEW_WITHDRAW_TOS + }.buttonStyle(TalerButtonStyle(type: .prominent)) + } + } + } // tooMany + } // invalid + Spacer() + } + .frame(maxWidth: .infinity, alignment: .leading) + .navigationTitle(navTitle) + .padding(.horizontal) + .onAppear { + symLog.log("onAppear") + DebugViewC.shared.setViewID(VIEW_WITHDRAWAL) + } + .task(id: centsToTransfer) { + let amount = Amount.amountFromCents(currency, centsToTransfer) + do { + manualWithdrawalDetails = try await model.loadWithdrawalDetailsForAmountM(exchange.exchangeBaseUrl, amount: amount) + if let ageRestrictions = manualWithdrawalDetails?.ageRestrictionOptions { + var ages = ageRestrictions + let nonzero = ages.count > 0 // need at least 1 value from exchange which is not 0 + if nonzero { + if ages[0] != 0 { // ensure that the first age is "0" + ages.insert(0, at: 0) // if not, insert "0" at position 0 + } + if selectedAge >= ages.count { // check for out of bounds + selectedAge = 0 + } + } else { + selectedAge = 0 // first ensure that selected is not out of bounds + } +print(ages) + ageMenuList = ages // set State (will update view) + } + } catch { // TODO: error + symLog.log(error.localizedDescription) + manualWithdrawalDetails = nil + } + } + } +} +// MARK: - +#if DEBUG +struct ManualWithdraw_Container : View { + @State private var centsToTransfer: UInt64 = 510 + @State private var manualWithdrawalDetails = ManualWithdrawalDetails(amountRaw: try! Amount(fromString: LONGCURRENCY + ":5.1"), + amountEffective: try! Amount(fromString: LONGCURRENCY + ":5.0"), + paytoUris: [], + tosAccepted: false, + ageRestrictionOptions: [], + numCoins: 6) + + var body: some View { + let model = WithdrawModel.model(baseURL: DEMOEXCHANGE) + let exchange = Exchange(exchangeBaseUrl: DEMOEXCHANGE, + currency: LONGCURRENCY, + paytoUris: [], + tosStatus: "tosStatus", + exchangeStatus: "exchangeStatus", + ageRestrictionOptions: [], + permanent: false) + ManualWithdraw(exchange: exchange, + model: model, + centsToTransfer: $centsToTransfer, + manualWithdrawalDetails: manualWithdrawalDetails) + } +} + +struct ManualWithdraw_Previews: PreviewProvider { + static var previews: some View { + ManualWithdraw_Container() + } +} +#endif diff --git a/TalerWallet1/Views/Exchange/ManualWithdrawDone.swift b/TalerWallet1/Views/Exchange/ManualWithdrawDone.swift new file mode 100644 index 0000000..3d698bf --- /dev/null +++ b/TalerWallet1/Views/Exchange/ManualWithdrawDone.swift @@ -0,0 +1,79 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +import SwiftUI +import taler_swift +import SymLog + +struct ManualWithdrawDone: View { + private let symLog = SymLogV() + let navTitle = String(localized: "Wire Transfer") + + var exchange: Exchange + @ObservedObject var model: WithdrawModel + var centsToTransfer: UInt64 + var restrictAge: Int? + @State var acceptManualWithdrawalResult: AcceptManualWithdrawalResult? + @State var withdrawalTransaction: Transaction? + + var body: some View { +#if DEBUG + let _ = Self._printChanges() + let _ = symLog.vlog() // just to get the # to compare it with .onAppear & onDisappear +#endif + VStack { + if let transaction = withdrawalTransaction { + TransactionDetailView(transaction: transaction, + doneAction: {ViewState.shared.popToRootView()}) + .navigationBarBackButtonHidden(true) // exit only by Done-Button + .navigationTitle(navTitle) + } else { + WithdrawProgressView(message: exchange.exchangeBaseUrl.trimURL()) + .navigationTitle("Loading " + navTitle) + } + }.onAppear() { + symLog.log("onAppear") + DebugViewC.shared.setViewID(VIEW_WITHDRAW_ACCEPT) + }.task { + do { + let amount = Amount.amountFromCents(exchange.currency!, centsToTransfer) + let result = try await model.sendAcceptManualWithdrawalM(exchange.exchangeBaseUrl, + amount: amount, restrictAge: restrictAge) +print(result as Any) + let transaction = try await model.getTransactionById(transactionId: result!.transactionId) + withdrawalTransaction = transaction +// acceptManualWithdrawalResult = result + } catch { // TODO: error + symLog.log(error.localizedDescription) + } + } + } +} + +// MARK: - +#if DEBUG +struct ManualWithdrawDone_Container : View { + @State private var centsToTransfer: UInt64 = 510 + + var body: some View { + let model = WithdrawModel.model(baseURL: DEMOEXCHANGE) + let exchange = Exchange(exchangeBaseUrl: DEMOEXCHANGE, + currency: LONGCURRENCY, + paytoUris: [], + tosStatus: "tosStatus", + exchangeStatus: "exchangeStatus", + ageRestrictionOptions: [], + permanent: false) + ManualWithdrawDone(exchange: exchange, + model: model, + centsToTransfer: centsToTransfer) + } +} + +struct ManualWithdrawDone_Previews: PreviewProvider { + static var previews: some View { + ManualWithdrawDone_Container() + } +} +#endif diff --git a/TalerWallet1/Views/HelperViews/AmountView.swift b/TalerWallet1/Views/HelperViews/AmountView.swift index 4249876..bf2b32f 100644 --- a/TalerWallet1/Views/HelperViews/AmountView.swift +++ b/TalerWallet1/Views/HelperViews/AmountView.swift @@ -1,17 +1,6 @@ /* - * 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/> + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md */ import SwiftUI @@ -19,13 +8,14 @@ struct AmountView: View { let title: String let value: String let color: Color + let large: Bool // set to false for QR or IBAN var body: some View { VStack { Text(title) .font(.title3) Text(value) - .font(.largeTitle) - .fontWeight(.medium) + .font(large ? .largeTitle : .title) + .fontWeight(large ? .medium : .regular) .foregroundColor(color) } .frame(maxWidth: .infinity, alignment: .center) @@ -35,9 +25,11 @@ struct AmountView: View { 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")) + List { + AmountView(title: "Fee", value: "- 0,2 Taler", + color: Color("Outgoing"), large: true) + AmountView(title: "Coins", value: "4,8 Taler", + color: Color("Incoming"), large: false) } } } diff --git a/TalerWallet1/Views/HelperViews/Buttons.swift b/TalerWallet1/Views/HelperViews/Buttons.swift index fd298cb..68d17eb 100644 --- a/TalerWallet1/Views/HelperViews/Buttons.swift +++ b/TalerWallet1/Views/HelperViews/Buttons.swift @@ -1,90 +1,248 @@ /* - * 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/> + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md */ import SwiftUI +import Foundation +import AVFoundation + + + + +extension ShapeStyle where Self == Color { + static var random: Color { + Color( + red: .random(in: 0...1), + green: .random(in: 0...1), + blue: .random(in: 0...1) + ) + } +} struct HamburgerButton : View { + var font: Font? let action: () -> Void var body: some View { Button(action: action) { Image(systemName: "line.3.horizontal") } - .font(.title) + .font(font ?? .title) + } +} + +struct QRButton : View { + var font: Font? + let action: () -> Void + + var body: some View { + Button(action: action) { + Image(systemName: "qrcode.viewfinder") + } + .font(font ?? .title) } } struct PlusButton : View { + var font: Font? let action: () -> Void var body: some View { Button(action: action) { Image(systemName: "plus") } - .font(.title) + .font(font ?? .title) } } struct ArrowUpButton : View { + var font: Font? let action: () -> Void var body: some View { Button(action: action) { Image(systemName: "arrow.up.to.line") } - .font(.title3) + .font(font ?? .title3) } } struct ArrowDownButton : View { + var font: Font? let action: () -> Void var body: some View { Button(action: action) { Image(systemName: "arrow.down.to.line") } - .font(.title3) + .font(font ?? .title3) } } struct ReloadButton : View { let disabled: Bool + var font: Font? let action: () -> Void var body: some View { Button(action: action) { Image(systemName: "arrow.clockwise") } - .font(.title) + .font(font ?? .title) .disabled(disabled) } } -struct AwesomeButton: View { +struct BigBlueButton: View { let title: String + var font: Font? + let disabled: Bool let action: () -> Void var body: some View { Button(action: action) { + let colors: [Color] = disabled ? [Color.gray, Color.blue] + : [Color.red, Color.blue] + let back = LinearGradient(gradient: Gradient(colors: colors), + startPoint: .leading, endPoint: .trailing) Text(title) - .frame(minWidth: 0, maxWidth: 300) + .frame(minWidth: 50, maxWidth: 500) .padding() .foregroundColor(.white) - .background(LinearGradient(gradient: Gradient(colors: [Color.red, Color.blue]), startPoint: .leading, endPoint: .trailing)) - .cornerRadius(40) - .font(.title) + .background(disabled ? .gray : .blue) // back + .cornerRadius(20) + .font(font ?? .title) + } + .disabled(disabled) + } +} +struct BorderedButton: View { + let title: String + var font: Font? + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(title) + .frame(minWidth: 50, maxWidth: 500) + .padding() + .font(font ?? .title) + } + .buttonStyle(.bordered) + } +} +struct ProminentButton: View { + let title: String + var font: Font? + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(title) + .frame(minWidth: 50, maxWidth: 500) + .padding() + .font(font ?? .title) } + .buttonStyle(.borderedProminent) + } +} + +struct TalerButtonStyle: ButtonStyle { + @Environment(\.isEnabled) private var isEnabled: Bool + func disabled() -> Bool { !isEnabled } + + enum TalerButtonStyleType { + case plain + case bordered + case prominent + } + var type: TalerButtonStyleType = .plain + var dimmed: Bool = false + var narrow: Bool = false + var aligned: TextAlignment = .center + + public func makeBody(configuration: ButtonStyle.Configuration) -> some View { + MyBigButton(type: type, + foreColor: foreColor(type: type, pressed: configuration.isPressed), + backColor: backColor(type: type, pressed: configuration.isPressed), + dimmed: dimmed, + configuration: configuration, + disabled: disabled(), + narrow: narrow, + aligned: aligned) + } + + func foreColor(type: TalerButtonStyleType, pressed: Bool) -> Color { + return if type == .plain { + WalletColors().fieldForeground + } else { + WalletColors().buttonForeColor(pressed: pressed, + disabled: disabled(), + prominent: type == .prominent) + } + } + func backColor(type: TalerButtonStyleType, pressed: Bool) -> Color { + return if type == .plain { + Color.clear + } else { + WalletColors().buttonBackColor(pressed: pressed, + disabled: disabled(), + prominent: type == .prominent) + } + } + struct BackgroundView: View { + let color: Color + let dimmed: Bool + var body: some View { + RoundedRectangle( + cornerRadius: 15, + style: .continuous + ) + .fill(color) + .opacity(dimmed ? 0.6 : 1.0) + } + } + + struct MyBigButton: View { + var type: TalerButtonStyleType + let foreColor: Color + let backColor: Color + let dimmed: Bool + let configuration: ButtonStyle.Configuration + let disabled: Bool + let narrow: Bool + let aligned: TextAlignment + + var body: some View { + let aligned2: Alignment = (aligned == .center) ? Alignment.center + : (aligned == .leading) ? Alignment.leading + : Alignment.trailing + configuration.label + .multilineTextAlignment(aligned) + .font(.title2) + .frame(minWidth: 0, maxWidth: narrow ? nil : .infinity, alignment: aligned2) + .padding(.vertical, 10) + .padding(.horizontal, 6) + .foregroundColor(foreColor) + .background(BackgroundView(color: backColor, dimmed: dimmed)) + .contentShape(Rectangle()) // make sure the button can be pressed even if backgroundColor == clear + .scaleEffect(configuration.isPressed ? 0.95 : 1) + .animation(.spring(response: 0.1), value: configuration.isPressed) + .disabled(disabled) + } + } +} + + + +struct GrowingButton: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .padding() + .background(.blue) + .foregroundColor(.white) + .clipShape(Capsule()) + .scaleEffect(configuration.isPressed ? 1.2 : 1) + .animation(.easeOut(duration: 0.2), value: configuration.isPressed) } } @@ -93,6 +251,8 @@ struct Buttons_Previews: PreviewProvider { VStack { HamburgerButton() {} .padding() + QRButton() {} + .padding() PlusButton() {} .padding() HStack { @@ -101,8 +261,46 @@ struct Buttons_Previews: PreviewProvider { ReloadButton(disabled: true) {} .padding() } - AwesomeButton(title: "AwesomeButton") {} + BigBlueButton(title: "DisabledButton", disabled: true) { AudioServicesPlaySystemSound(1015) } + .padding() + BigBlueButton(title: "BigBlueButton", disabled: false) { AudioServicesPlaySystemSound(1000) } + .padding() + } + } +} + +#if DEBUG +struct ContentView: View { + @State var isOn = false + //The better route is to have a separate variable to control the animations + // This prevents unpleasant side-effects. + @State private var animate = false + + var body: some View { + VStack { + Text("I don't change.") .padding() + Button("Press me, I do change") { + isOn.toggle() + animate = false + // Because .opacity is animated, we need to switch it + // back so the button shows. + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + animate = true + } + } + // In this case I chose to animate .opacity + .opacity(animate ? 1 : 0) + .animation(.easeIn, value: animate) + .frame(width: 300, height: 400) + // If you want the button to animate when the view appears, you need to change the value + .onAppear { animate = true } } } } +struct ContentView_Previews: PreviewProvider { + static var previews: some View { + ContentView() + } +} +#endif diff --git a/TalerWallet1/Views/HelperViews/CopyShare.swift b/TalerWallet1/Views/HelperViews/CopyShare.swift new file mode 100644 index 0000000..19e06a1 --- /dev/null +++ b/TalerWallet1/Views/HelperViews/CopyShare.swift @@ -0,0 +1,85 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +import UniformTypeIdentifiers +import SwiftUI +import SymLog + +struct CopyButton: View { + private let symLog = SymLogV(0) + @Environment(\.isEnabled) private var isEnabled: Bool + let textToCopy: String + let vertical: Bool + + func copyAction() -> Void { + symLog.log(textToCopy) + UIPasteboard.general.setValue(textToCopy, + forPasteboardType: UTType.plainText.identifier) + } + + var body: some View { + Button(action: copyAction) { + if vertical { + VStack { + Image(systemName: "doc.on.doc") + Text("Copy", comment: "5 letters max, else abbreviate") + } + } else { + HStack { + Image(systemName: "doc.on.doc") + Text("Copy", comment: "may be a bit longer") + } + } + } + .disabled(!isEnabled) + } +} +// MARK: - +struct ShareButton: View { + private let symLog = SymLogV(0) + @Environment(\.isEnabled) private var isEnabled: Bool + + let textToShare: String + let dismissFirst: Bool + + func shareAction() -> Void { + if dismissFirst { + dismissTop() // cannot open another sheet from within a sheet + } + symLog.log(textToShare) + ShareSheet.shareSheet(url: textToShare) + } + + var body: some View { + Button(action: shareAction) { + HStack { + Image(systemName: "square.and.arrow.up") + Text("Share") + } + } + .disabled(!isEnabled) + } +} +// MARK: - +struct CopyShare: View { + @Environment(\.isEnabled) private var isEnabled: Bool + + let textToCopy: String + let dismissFirst: Bool + + var body: some View { + HStack { + CopyButton(textToCopy: textToCopy, vertical: false) + .buttonStyle(TalerButtonStyle(type: .bordered)) + ShareButton(textToShare: textToCopy, dismissFirst: dismissFirst) + .buttonStyle(TalerButtonStyle(type: .bordered)) + } // two buttons + } +} +// MARK: - +struct CopyShare_Previews: PreviewProvider { + static var previews: some View { + CopyShare(textToCopy: "Hallö", dismissFirst: false) + } +} diff --git a/TalerWallet1/Views/HelperViews/CurrencyField.swift b/TalerWallet1/Views/HelperViews/CurrencyField.swift new file mode 100644 index 0000000..8f2e525 --- /dev/null +++ b/TalerWallet1/Views/HelperViews/CurrencyField.swift @@ -0,0 +1,221 @@ +/* MIT License + * Copyright (c) 2022 Javier Trinchero + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import SwiftUI +import UIKit + +public struct CurrencyField: View { + @Binding var value: UInt64 + var currency: String + var formatter: NumberFormatter + private var currencyInputField: CurrencyInputField! = nil + + public func becomeFirstResponder() -> Void { + currencyInputField.becomeFirstResponder() + } + + public func resignFirstResponder() -> Void { + currencyInputField.resignFirstResponder() + } + + private var label: String { + let mag = pow(10, formatter.maximumFractionDigits) + return formatter.string(for: Decimal(value) / mag) ?? "" + } + + public init(value: Binding<UInt64>, currency: String, formatter: NumberFormatter) { + self._value = value + self.currency = currency + self.formatter = formatter + self.currencyInputField = CurrencyInputField(value: $value, formatter: formatter) + } + + public init(value: Binding<UInt64>, currency: String) { + let formatter = NumberFormatter() + formatter.locale = .current + formatter.numberStyle = .currency + formatter.currencySymbol = currency + formatter.minimumFractionDigits = 2 + formatter.maximumFractionDigits = 2 + + self.init(value: value, currency: currency, formatter: formatter) + } + + public var body: some View { + ZStack { + // Text view to display the formatted currency + // Set as priority so CurrencyInputField size doesn't affect parent + Text(label) + .layoutPriority(1) + + // Input text field to handle UI + currencyInputField + } + } +} + +// Sub-class UITextField to remove selection and caret +class NoCaretTextField: UITextField { + override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { + false + } + + override func selectionRects(for range: UITextRange) -> [UITextSelectionRect] { + [] + } + + override func caretRect(for position: UITextPosition) -> CGRect { + .null + } +} + +struct CurrencyInputField: UIViewRepresentable { + @Binding var value: UInt64 + var formatter: NumberFormatter + private let textField = NoCaretTextField(frame: .zero) + + func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + public func becomeFirstResponder() -> Void { + textField.becomeFirstResponder() + } + + public func resignFirstResponder() -> Void { + textField.resignFirstResponder() + } + + func makeUIView(context: Context) -> NoCaretTextField { + // Assign delegate + textField.delegate = context.coordinator + + // Set keyboard type + textField.keyboardType = .numberPad + + // Make visual components invisible + textField.tintColor = .clear + textField.textColor = .clear + textField.backgroundColor = .clear + + // Add editingChanged event handler + textField.addTarget( + context.coordinator, + action: #selector(Coordinator.editingChanged(textField:)), + for: .editingChanged + ) + + // Set initial textfield text + context.coordinator.updateText(value, textField: textField) + + return textField + } + + func updateUIView(_ uiView: NoCaretTextField, context: Context) {} + + class Coordinator: NSObject, UITextFieldDelegate { + // Reference to currency input field + private var input: CurrencyInputField + + // Last valid text input string to be displayed + private var lastValidInput: String? = "" + + init(_ currencyTextField: CurrencyInputField) { + self.input = currencyTextField + } + + func setValue(_ value: UInt64, textField: UITextField) { + // Update input value + input.value = value + + // Update textfield text + updateText(value, textField: textField) + } + + func updateText(_ value: UInt64, textField: UITextField) { + // Update field text and last valid input text + textField.text = String(value) + lastValidInput = String(value) + } + + func textField(_ textField: UITextField, shouldChangeCharactersIn range: NSRange, replacementString string: String) -> Bool { + // If replacement string is empty, we can assume the backspace key was hit + if string.isEmpty { + // Resign first responder when delete is hit when value is 0 + if input.value == 0 { + textField.resignFirstResponder() + } + + // Remove trailing digit + setValue(UInt64(input.value / 10), textField: textField) + } + return true + } + + @objc func editingChanged(textField: NoCaretTextField) { + // Get a mutable copy of last text + guard var oldText = lastValidInput else { + return + } + + // Iterate through each char of the new string and compare LTR with old string + let char = (textField.text ?? "").first { next in + // If old text is empty or its next character doesn't match new + if oldText.isEmpty || next != oldText.removeFirst() { + // Found the mismatching character + return true + } + return false + } + + // Find new character and try to get an Int value from it + guard let char = char, let digit = Int(String(char)) else { + // New character could not be converted to Int + // Revert to last valid text + textField.text = lastValidInput + return + } + + // Multiply by 10 to shift numbers one position to the left, revert if an overflow occurs + let (multValue, multOverflow) = input.value.multipliedReportingOverflow(by: 10) + if multOverflow { + textField.text = lastValidInput + return + } + + // Add the new trailing digit, revert if an overflow occurs + let (addValue, addOverflow) = multValue.addingReportingOverflow(UInt64(digit)) + if addOverflow { + textField.text = lastValidInput + return + } + + // If new value has more digits than allowed by formatter, revert + if input.formatter.maximumFractionDigits + input.formatter.maximumIntegerDigits < String(addValue).count { + textField.text = lastValidInput + return + } + + // Update new value + setValue(addValue, textField: textField) + } + } +} diff --git a/TalerWallet1/Views/HelperViews/CurrencyInputView.swift b/TalerWallet1/Views/HelperViews/CurrencyInputView.swift new file mode 100644 index 0000000..da84250 --- /dev/null +++ b/TalerWallet1/Views/HelperViews/CurrencyInputView.swift @@ -0,0 +1,60 @@ +// +// CurrencyInputView.swift +// TalerWalletT +// +// Created by Marc Stibane on 2023-06-04. +// Copyright © 2023 Taler. All rights reserved. +// + +import SwiftUI + +struct CurrencyInputView: View { + let currencyField: CurrencyField + let title: String + + @State var hasBeenShown = false + var body: some View { + VStack (alignment: .leading) { + Text(title) + .padding(.top) + .font(.title3) + currencyField + .frame(maxWidth: .infinity, alignment: .trailing) + .foregroundColor(WalletColors().fieldForeground) // text color + .background(WalletColors().fieldBackground) + .font(.title) + .border(.primary) + }.onAppear { // make CurrencyField show the keyboard after 0.4 seconds + if hasBeenShown { + print("❗️Yikes: CurrencyInputView hasBeenShown") + } else { + print("❗️Yikes: First CurrencyInputView❗️") + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + hasBeenShown = true + currencyField.becomeFirstResponder() + } + } + }.onDisappear { + currencyField.resignFirstResponder() + } + } +} +#if DEBUG +fileprivate struct BindingViewContainer : View { + @State var centsToTransfer: UInt64 = 0 + + var body: some View { + let currencyField = CurrencyField(value: $centsToTransfer, currency: LONGCURRENCY) + CurrencyInputView(currencyField: currencyField, + title: "Amount to withdraw:") + } +} + +struct CurrencyInputView_Previews: PreviewProvider { + static var previews: some View { + List { + BindingViewContainer() + } + } +} +#endif diff --git a/TalerWallet1/Views/Main/LaunchAnimationView.swift b/TalerWallet1/Views/HelperViews/LaunchAnimationView.swift index cc20fa4..03a9eac 100644 --- a/TalerWallet1/Views/Main/LaunchAnimationView.swift +++ b/TalerWallet1/Views/HelperViews/LaunchAnimationView.swift @@ -1,23 +1,24 @@ - +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ 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) + .publish(every: 1.6, on: .current, in: .common) .autoconnect() var body: some View { ZStack { - Color.teal.ignoresSafeArea() - Image(systemName: "hurricane") + Color(.systemGray6).ignoresSafeArea() + Image("taler-logo-2023-red") .resizable() .scaledToFit() - .frame(width: 200, height: 200) - .rotationEffect(rotationDirection ? Angle(degrees: 0) : Angle(degrees: 1080)) + .frame(width: 250, height: 250) + .rotationEffect(rotationDirection ? Angle(degrees: 0) : Angle(degrees: 900)) } .onReceive(animationTimer) { timerValue in withAnimation(.easeInOut(duration: 1.9)) { @@ -26,6 +27,7 @@ struct LaunchAnimationView: View { } } } +// MARK: - struct LaunchAnimationView_Previews: PreviewProvider { static var previews: some View { LaunchAnimationView() diff --git a/TalerWallet1/Views/HelperViews/ListStyle.swift b/TalerWallet1/Views/HelperViews/ListStyle.swift new file mode 100644 index 0000000..409e275 --- /dev/null +++ b/TalerWallet1/Views/HelperViews/ListStyle.swift @@ -0,0 +1,113 @@ +/* MIT License + * Copyright (c) 2022 young rtSwift + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import SwiftUI + +public extension View { + var anyView: AnyView { + AnyView(self) + } +} +// MARK: - +// Our ListStyle each case corresponds to a SwiftUI.ListStyle +// Here we make it CaseIterable for SwiftUI.ForEach +// and the UI Display name +public enum MyListStyle: String, CaseIterable, Hashable { + case automatic = "Automatic" + case grouped = "Grouped" + case inset = "Inset" + case insetGrouped = "InsetGrouped" + case plain = "Plain" + case sidebar = "Sidebar" + + // map to SwiftUI ListStyle + var style: any SwiftUI.ListStyle { + switch self { + case .automatic: return .automatic + case .grouped: return .grouped + case .inset: return .inset + case .insetGrouped: return .insetGrouped + case .plain: return .plain + case .sidebar: return .sidebar + } + } + + var displayName: String { + String(self.rawValue) + } +} +// MARK: - +#if DEBUG +struct AnyViewDemo: View { + @State private var selectedStyle = MyListStyle.automatic + + let sections = ["Breakfast", "Lunch", "Dinner"] + let breakfast = ["pancakes", "bacon", "orange juice"] + var body: some View { + VStack { + Picker("List Style", selection: $selectedStyle) { + ForEach(MyListStyle.allCases, id: \.self) { + Text($0.displayName).tag($0) + } + } + + List(sections, id: \.self) { section in + Section(section) { + if "Breakfast" == section { + ForEach(breakfast, id: \.self) { item in + Text(item) + } + } else { + Text("row") + } + } +// Section("Breakfast") { +// Text("pancakes") +// Text("bacon") +// Text("orange juice") +// } +// Section("Lunch") { +// Text("sandwich") +// Text("chips") +// Text("lemonade") +// } +// Section("Dinner") { +// Text("spaghetti") +// Text("bread") +// Text("water") +// } + } + .refreshable { + print("refreshing") + // this closure is already async, no need for a Task + } + .listStyle(selectedStyle.style) + .anyView + } + } +} + +struct AnyViewDemo_Previews: PreviewProvider { + static var previews: some View { + AnyViewDemo() + } +} +#endif diff --git a/TalerWallet1/Views/HelperViews/LoadingView.swift b/TalerWallet1/Views/HelperViews/LoadingView.swift index 10c245e..0601ae0 100644 --- a/TalerWallet1/Views/HelperViews/LoadingView.swift +++ b/TalerWallet1/Views/HelperViews/LoadingView.swift @@ -1,45 +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/> + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md */ import SwiftUI import SymLog struct LoadingView: View { private let symLog = SymLogV(0) + let navTitle = String(localized: "Loading...") let backButtonHidden: Bool var body: some View { - symLog { NavigationView { - VStack { - Spacer() - ProgressView() - Spacer() - Spacer() - Spacer() - } - .navigationBarBackButtonHidden(backButtonHidden) - .navigationTitle("Loading...") - } + LaunchAnimationView() + .navigationBarBackButtonHidden(backButtonHidden) + .navigationTitle(navTitle) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) - .background(Color(.systemGray6)) - } + .background(WalletColors().backgroundColor) } } - +// MARK: - struct LoadingView_Previews: PreviewProvider { static var previews: some View { - LoadingView(backButtonHidden: true) + NavigationView { + LoadingView(backButtonHidden: true) + } } } diff --git a/TalerWallet1/Views/HelperViews/QRGeneratorView.swift b/TalerWallet1/Views/HelperViews/QRGeneratorView.swift new file mode 100644 index 0000000..9f51b37 --- /dev/null +++ b/TalerWallet1/Views/HelperViews/QRGeneratorView.swift @@ -0,0 +1,62 @@ +/* MIT License + * Copyright (c) 2020 Jeeva Tamilselvan + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ +import SwiftUI + +struct QRGeneratorView: View { + var text: String + + var body: some View { + if let data = getQRCodeData(text: text) { + if let uiImage = UIImage(data: data) { + Image(uiImage: uiImage) + .interpolation(.none) + .resizable() + .scaledToFit() + .frame(width: 200, height: 200) + } else { + EmptyView() + } + } else { + EmptyView() + } + } + + func getQRCodeData(text: String) -> Data? { + guard let filter = CIFilter(name: "CIQRCodeGenerator") else { return nil } + let data = text.data(using: .ascii, allowLossyConversion: false) + filter.setValue(data, forKey: "inputMessage") + guard let ciimage = filter.outputImage else { return nil } + let transform = CGAffineTransform(scaleX: 10, y: 10) + let scaledCIImage = ciimage.transformed(by: transform) + let uiimage = UIImage(ciImage: scaledCIImage) + return uiimage.pngData()! + } +} + +struct QRGeneratorView_Previews: PreviewProvider { + static var previews: some View { + VStack { + QRGeneratorView(text: "Hello World!") + QRGeneratorView(text: "taler://pay-pull/exchange.demo.taler.net/7J7SNHYMCCAZ1ARY9YCB5Z9FTY0YZP8F2KDRXV94KZCQ6WAVMTX0") + } + } +} diff --git a/TalerWallet1/Views/HelperViews/SelectDays.swift b/TalerWallet1/Views/HelperViews/SelectDays.swift new file mode 100644 index 0000000..8fb1905 --- /dev/null +++ b/TalerWallet1/Views/HelperViews/SelectDays.swift @@ -0,0 +1,56 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +import SwiftUI +import taler_swift +import SymLog + +struct SelectDays: View { + private let symLog = SymLogV(0) + @Environment(\.isEnabled) private var isEnabled: Bool + + @Binding var selected: UInt + let maxExpiration: UInt + + func oneDayAction() -> Void { + selected = 1 + symLog.log(selected) + } + + func sevenDayAction() -> Void { + selected = 7 + symLog.log(selected) + } + + func thirtyDayAction() -> Void { + selected = 30 + symLog.log(selected) + } + + var body: some View { + HStack { + Button(action: oneDayAction) { + Text("1 Day") + }.buttonStyle(TalerButtonStyle(type: (selected == 1) ? .prominent : .bordered, dimmed: true)) + .disabled(!isEnabled) + + Button(action: sevenDayAction) { + Text("7 Days") + }.buttonStyle(TalerButtonStyle(type: (selected == 7) ? .prominent : .bordered, dimmed: true)) + .disabled(!isEnabled || maxExpiration < 7) + + Button(action: thirtyDayAction) { + Text("30 Days") + }.buttonStyle(TalerButtonStyle(type: (selected == 30) ? .prominent : .bordered, dimmed: true)) + .disabled(!isEnabled || maxExpiration < 30) + } // 3 buttons + } +} + +struct SelectDays_Previews: PreviewProvider { + static var previews: some View { + @State var expireDays: UInt = 1 + SelectDays(selected: $expireDays, maxExpiration: 20) + } +} diff --git a/TalerWallet1/Views/HelperViews/TextFieldAlert.swift b/TalerWallet1/Views/HelperViews/TextFieldAlert.swift index de2eaf6..e307ff1 100644 --- a/TalerWallet1/Views/HelperViews/TextFieldAlert.swift +++ b/TalerWallet1/Views/HelperViews/TextFieldAlert.swift @@ -1,17 +1,6 @@ /* - * 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/> + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md */ import SwiftUI diff --git a/TalerWallet1/Views/Main/ContentView.swift b/TalerWallet1/Views/Main/ContentView.swift deleted file mode 100644 index 1609351..0000000 --- a/TalerWallet1/Views/Main/ContentView.swift +++ /dev/null @@ -1,92 +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 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 index 25fc2f0..50e0515 100644 --- a/TalerWallet1/Views/Main/ErrorView.swift +++ b/TalerWallet1/Views/Main/ErrorView.swift @@ -1,31 +1,23 @@ /* - * 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/> + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md */ import SwiftUI import SymLog struct ErrorView: View { private let symLog = SymLogV() + + let errortext: String? + var body: some View { - Text("Couldn't load Wallet-Core!") + Text(errortext ?? "Couldn't load Wallet-Core!") } } struct ErrorView_Previews: PreviewProvider { static var previews: some View { - ErrorView() + ErrorView(errortext: "") } } diff --git a/TalerWallet1/Views/Main/MainView.swift b/TalerWallet1/Views/Main/MainView.swift new file mode 100644 index 0000000..e6ce3f1 --- /dev/null +++ b/TalerWallet1/Views/Main/MainView.swift @@ -0,0 +1,100 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +import SwiftUI +import SymLog + +// Use this to delay instantiation when using `NavigationLink`, etc... +struct LazyView<Content: View>: View { + var content: () -> Content + var body: some View { + self.content() + } +} + +struct MainView: View { + private let symLog = SymLogV() + @EnvironmentObject private var viewState: ViewState // popToRootView() + @EnvironmentObject private var controller: Controller + @State private var sheetPresented = false + @State private var urlToOpen: URL? = nil + + func sheetDismiss() -> Void { + symLog.log("sheet dismiss") + } + var body: some View { +#if DEBUG + let _ = Self._printChanges() + let _ = symLog.vlog() // just to get the # to compare it with .onAppear & onDisappear +#endif + NavigationView { // the one and only for all non-sheet views + if controller.backendState == .ready { + Content(symLog: symLog) + // any change to rootViewId triggers popToRootView behaviour + .id(viewState.rootViewId) + .onOpenURL { url in + symLog.log(".onOpenURL: \(url)") + // will be called on a taler:// scheme either + // by user tapping such link in a browser (bank website) + // or when launching the app from iOS Camera.app scanning a QR code + urlToOpen = url // raise sheet + } + } else if controller.backendState == .error { + ErrorView(errortext: nil) // TODO: show Error View + } else { + LaunchAnimationView() + } + }.navigationViewStyle(.stack) + .overlay(alignment: .top) { + // Show the viewID on top of the app's NavigationView + DebugViewV() + .id("ViewID") + } + .sheet(item: $urlToOpen, onDismiss: sheetDismiss) { url in + let sheet = AnyView(URLSheet(urlToOpen: url)) + Sheet(sheetView: sheet) + } + } // body +} +// MARK: - Content +extension MainView { + struct Content: View { + let symLog: SymLogV? + + @State var sidebarVisible: Bool = false + @State var currentView: Int = 0 + + var views: [SidebarItem] {[ + SidebarItem(name: String(localized: "Balances"), + sysImage: "creditcard.fill", // TODO: Wallet Icon + view: AnyView(BalancesListView(model: BalancesModel.model(currency: "*")) + { sidebarVisible = !sidebarVisible } // hamburgerAction + )), + SidebarItem(name: String(localized: "Exchanges"), + sysImage: "building.columns", + view: AnyView(ExchangeListView(model: ExchangeModel.model()) + { sidebarVisible = !sidebarVisible } // hamburgerAction + )), + SidebarItem(name: String(localized: "Settings"), // TODO: "About"? + sysImage: "gearshape.fill", + view: AnyView(SettingsView() + { sidebarVisible = !sidebarVisible } // hamburgerAction + )) + ]} + var body: some View { +#if DEBUG + let _ = Self._printChanges() + let _ = symLog?.vlog() // just to get the # to compare it with .onAppear & onDisappear +#endif + ZStack(alignment: .leading) { + views[currentView].view + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) + .id(views[currentView].name) + .transition(.backslide) + SideBarView(views: views, currentView: $currentView, sidebarVisible: $sidebarVisible) + } + .background(WalletColors().backgroundColor) + } + } +} diff --git a/TalerWallet1/Views/Main/SideBarView.swift b/TalerWallet1/Views/Main/SideBarView.swift index bec2b2c..db50a2c 100644 --- a/TalerWallet1/Views/Main/SideBarView.swift +++ b/TalerWallet1/Views/Main/SideBarView.swift @@ -1,22 +1,11 @@ /* - * 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/> + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md */ import SwiftUI import SymLog -fileprivate let sidebarWidth = 250.0 +fileprivate let sidebarWidth = 220.0 struct SidebarItem { var name: String @@ -31,15 +20,21 @@ struct SideBarView: View { @Binding var sidebarVisible: Bool var body: some View { - symLog { - HStack { + HStack { // sideView left, clear dismiss target right + EqualIconWidthDomain { VStack { Spacer() + Image("taler-logo-2023-red") + .resizable() + .scaledToFit() + .frame(width: 100, height: 100) + .padding(.top, 30) 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 + withAnimation(.easeInOut) {currentView = i} // switch to the view the user selected +// withAnimation(.easeInOut(duration: 0.5)) {currentView = i} // switch to the view the user selected } label: { if let sysImage = views[i].sysImage { Label(views[i].name, systemImage: sysImage) @@ -51,14 +46,24 @@ struct SideBarView: View { } .buttonStyle(.bordered) .font(.title) -// .padding(.vertical) - -// Divider() + // .padding(.vertical) } Spacer() Spacer() + Spacer() +// Button { +// symLog.log("Scan QR selected") +// sidebarVisible = false // slide sidebar to the left + // TODO: show scan sheet +// } label: { +// Label("Scan QR", systemImage: "qrcode.viewfinder") +// .frame(maxWidth: sidebarWidth, alignment: .leading) +// } +// .buttonStyle(.bordered) +// .font(.title) +// .padding(.bottom, 26.0) } - .background(Color(.systemGray5)) + .background(WalletColors().sideBackground) .frame(width: sidebarWidth, alignment: .center) // TODO: use leading instead of sidebarWidth for right-to-left .offset(x: sidebarVisible ? 0 : -sidebarWidth) @@ -67,23 +72,23 @@ struct SideBarView: View { // .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 - } } + 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 + } } } } - +// MARK: - #if DEBUG -struct BindingViewContainer : View { +fileprivate struct BindingViewContainer : View { @State var currentView: Int = 0 @State var sidebarVisible: Bool = true var views: [SidebarItem] @@ -99,8 +104,10 @@ struct BindingViewContainer : View { struct SideBarView_Previews: PreviewProvider { static var views: [SidebarItem] {[ SidebarItem(name: "Balances", + sysImage: "creditcard.fill", // TODO: Wallet Icon view: AnyView(WalletEmptyView())), SidebarItem(name: "Settings", + sysImage: "gearshape.fill", view: AnyView(WalletEmptyView())) ]} static var previews: some View { diff --git a/TalerWallet1/Views/Main/WalletEmptyView.swift b/TalerWallet1/Views/Main/WalletEmptyView.swift new file mode 100644 index 0000000..a6a810a --- /dev/null +++ b/TalerWallet1/Views/Main/WalletEmptyView.swift @@ -0,0 +1,44 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +import SwiftUI +import SymLog + +/// This view shows hints if a wallet is empty +/// It is the very first thing the user sees after installing the app + +struct WalletEmptyView: View { + private let symLog = SymLogV() + @AppStorage("listStyle") var myListStyle = MyListStyle.automatic + + var body: some View { + List { + Section { + Text("There is no digital cash in your wallet.") + } + Section { + Text("You can get some test money from the demo bank:") + } + Section { + Link(DEMOBANK, destination: URL(string: DEMOBANK)!) + } + Section { + Text("Just register a test account, then withdraw some coins.") + } + } + .padding(.vertical) + .font(.title2) + .listStyle(myListStyle.style) + .anyView + .onAppear() { + DebugViewC.shared.setViewID(VIEW_EMPTY) // 10 + } + } +} + +struct WalletEmptyView_Previews: PreviewProvider { + static var previews: some View { + WalletEmptyView() + } +} diff --git a/TalerWallet1/Views/Payment/PaymentAcceptView.swift b/TalerWallet1/Views/Payment/PaymentAcceptView.swift index 26eb916..3b15583 100644 --- a/TalerWallet1/Views/Payment/PaymentAcceptView.swift +++ b/TalerWallet1/Views/Payment/PaymentAcceptView.swift @@ -1,71 +1,74 @@ /* - * 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/> + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md */ import SwiftUI import taler_swift +import AVFoundation import SymLog struct PaymentAcceptView: View { private let symLog = SymLogV() - @ObservedObject var viewModel: PaymentURIModel + @ObservedObject var model: PaymentURIModel - var detailsForAmount: PaymentDetailsForUri + let 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() } - } + let navTitle = String(localized: "Accept Payment") + @State private var confirmPayResult: ConfirmPayResult? - @State var confirmPayResult: ConfirmPayResult? + func playSound(success: Bool) { +// let url = URL(fileURLWithPath: "/System/Library/Audio/UISounds/PaymentReceived.caf") + let url = URL(fileURLWithPath: "/System/Library/Audio/UISounds/payment_" + (success ? "success.caf" + : "failure.caf")) + var soundID: SystemSoundID = 0 + AudioServicesCreateSystemSoundID(url as CFURL, &soundID) + print(soundID) + AudioServicesPlaySystemSound(soundID); + } + func acceptAction() { + Task { + do { + confirmPayResult = try await model.confirmPayM(detailsForAmount.proposalId) + symLog.log(confirmPayResult as Any) + if confirmPayResult?.type == "done" { + // TODO: Show Hints that Payment was successfull + playSound(success: true) + } else { + // TODO: show error + playSound(success: false) + } + } catch { // TODO: error + symLog.log(error.localizedDescription) + } + dismissTop() + } + } + @State private var disabled = false var body: some View { - symLog { Group { + Group { let raw = detailsForAmount.amountRaw let effective = detailsForAmount.amountEffective let fee = try! Amount.diff(raw, effective) // TODO: different currencies - 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() - } + ThreeAmountsView(topTitle: "Amount to pay:", + topAmount: raw, fee: fee, + bottomTitle: "Coins to be spent:", + bottomAmount: effective, + large: true, pending: false, incoming: false, + baseURL: detailsForAmount.contractTerms.exchanges.first?.url) + // TODO: payment: popup with all possible exchanges, check fees + .safeAreaInset(edge: .bottom) { + ProminentButton(title: "Accept", action: acceptAction) + .padding(.horizontal) } } - .navigationBarItems(leading: cancelButton) .navigationTitle(navTitle) - } } } + +//struct PaymentAccept_Previews: PreviewProvider { +// static var previews: some View { +// let model: PaymentURIModel = +// PaymentAcceptView(model: <#PaymentURIModel#>, detailsForAmount: <#PaymentDetailsForUri#>) +// } +//} diff --git a/TalerWallet1/Views/Payment/PaymentURIView.swift b/TalerWallet1/Views/Payment/PaymentURIView.swift index 38ad04c..8815be7 100644 --- a/TalerWallet1/Views/Payment/PaymentURIView.swift +++ b/TalerWallet1/Views/Payment/PaymentURIView.swift @@ -1,17 +1,6 @@ /* - * 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/> + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md */ import SwiftUI import SymLog @@ -19,46 +8,38 @@ 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() } - } - + @ObservedObject var model: PaymentURIModel @State var detailsForUri: PaymentDetailsForUri? + let navTitle = String(localized: "Payment") + var body: some View { let badURL = "Error in URL: \(url)" VStack { - if viewModel.paymentState == nil { + if model.paymentState == nil { LoadingView(backButtonHidden: false) - } else { switch viewModel.paymentState { + } else { switch model.paymentState { case .waitingForUriDetails: let _ = symLog.vlog("waitingForUriDetails") - WithdrawProgressView(message: url.host ?? badURL) { dismissTop() } + WithdrawProgressView(message: url.host ?? badURL) .navigationTitle("Contacting Exchange") case .receivedUriDetails: let _ = symLog.vlog("waitingForUser") - PaymentAcceptView(viewModel: viewModel, detailsForAmount: detailsForUri!) + PaymentAcceptView(model: model, detailsForAmount: detailsForUri!) default: - symLog { - Text("Payment") - .navigationBarItems(leading: cancelButton) - .navigationTitle(navTitle) - } + Text("Payment") + .navigationTitle(navTitle) } } }.task { do { // TODO: cancelled symLog.log(".task") - detailsForUri = try await viewModel.preparePayForUri(url.absoluteString) + detailsForUri = try await model.preparePayForUriM(url.absoluteString) // print(detailsForUri?.status) // print(detailsForUri?.amountRaw.description) // print(detailsForUri?.amountEffective.description) // print(detailsForUri?.proposalId) - } catch { - symLog.log(error.localizedDescription) // TODO: error + } catch { // TODO: error + symLog.log(error.localizedDescription) } } } diff --git a/TalerWallet1/Views/Peer2peer/ReceivePurpose.swift b/TalerWallet1/Views/Peer2peer/ReceivePurpose.swift new file mode 100644 index 0000000..38be62b --- /dev/null +++ b/TalerWallet1/Views/Peer2peer/ReceivePurpose.swift @@ -0,0 +1,129 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +import SwiftUI +import taler_swift +import SymLog + +struct ReceivePurpose: View { + private let symLog = SymLogV() + @FocusState private var isFocused: Bool + @StateObject var model = Peer2peerModel.model() + @State var peerPullCheck: CheckPeerPullCreditResponse? + + var scopeInfo: ScopeInfo + var centsToTransfer: UInt64 + @Binding var purpose: String + @Binding var expireDays: UInt + var deactivateAction: () -> Void + + let formatter = CurrencyFormatter.shared // TODO: based on currency + + let buttonFont: Font = .title2 + private var label: String { + let mag = pow(10, formatter.maximumFractionDigits) + return formatter.string(for: Decimal(centsToTransfer) / mag) ?? "" + } + + func pullFee(ppCheck: CheckPeerPullCreditResponse?) -> String { + do { + if let p2pcheck = ppCheck { + let fee = try p2pcheck.amountRaw - p2pcheck.amountEffective + return fee.readableDescription + } + } catch {} + return "" + } + + var body: some View { + let amount = Amount.amountFromCents(scopeInfo.currency, centsToTransfer) + + let fee = pullFee(ppCheck: peerPullCheck) + VStack (spacing: 6) { + Text(amount.readableDescription) + Text("+ \(fee) payment fee") + .foregroundColor(.red) + VStack(alignment: .leading, spacing: 6) { + Text("Purpose:") + .padding(.top) + .font(.title3) + + TextField("Purpose", text: $purpose) + .font(.title) + .foregroundColor(WalletColors().fieldForeground) // text color + .background(WalletColors().fieldBackground) + .border(.primary) + .focused($isFocused) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + isFocused = true // make first responder - raise keybord + } + } + + HStack{ + Spacer() + Text("\(purpose.count)/100") + } // maximum 100 characters + + Text("Expires in:") + .font(.title3) + + SelectDays(selected: $expireDays, maxExpiration: 35) + .disabled(false) + .padding(.bottom) + + let buttonTitle = "Invoice \(label) \(scopeInfo.currency)" + let disabled = (expireDays == 0) || (purpose.count < 1) + + NavigationLink(destination: LazyView { + SendNow(amountToSend: amount, purpose: purpose, expireDays: expireDays, model: model) + .background(WalletColors().backgroundColor.edgesIgnoringSafeArea(.all)) + }) { + Text(buttonTitle) + .font(buttonFont) + } + .buttonStyle(TalerButtonStyle(type: .prominent)) + .disabled(disabled) + + Spacer() + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + } + .navigationTitle("Invoice another Wallet") + .onAppear { + DebugViewC.shared.setSheetID(VIEW_INVOICE_PURPOSE) + print("❗️ ReceivePurpose onAppear") + } + .onDisappear { + print("❗️ ReceivePurpose onDisappear") + deactivateAction() + } + .task { + symLog.log(".task") + do { + peerPullCheck = try await model.checkPeerPullCreditM(amount, exchangeBaseUrl: scopeInfo.exchangeBaseUrl ?? nil) // TODO: exchangeBaseUrl! + } catch { // TODO: error + symLog.log(error.localizedDescription) + } + } + } + +} +// MARK: - +#if DEBUG +struct ReceivePurpose_Previews: PreviewProvider { + static var previews: some View { + let scopeInfo = ScopeInfo(type: ScopeInfo.ScopeInfoType.exchange, exchangeBaseUrl: DEMOEXCHANGE, currency: LONGCURRENCY) + @State var purpose: String = "pUrPoSe" + @State var expireDays: UInt = 0 + ReceivePurpose(scopeInfo: scopeInfo, + centsToTransfer: 5, + purpose: $purpose, + expireDays: $expireDays) { + print("deactivateAction") + } + } +} +#endif diff --git a/TalerWallet1/Views/Peer2peer/RequestPayment.swift b/TalerWallet1/Views/Peer2peer/RequestPayment.swift new file mode 100644 index 0000000..0c37649 --- /dev/null +++ b/TalerWallet1/Views/Peer2peer/RequestPayment.swift @@ -0,0 +1,77 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +import SwiftUI +import taler_swift +import SymLog + +struct RequestPayment: View { + private let symLog = SymLogV() + let navTitle = String(localized: "Request Payment") + + @ObservedObject var model: Peer2peerModel + var scopeInfo: ScopeInfo + @Binding var centsToTransfer: UInt64 + + @State private var peerPullCheck: CheckPeerPullCreditResponse? = nil + @State private var purpose: String = "" + @State private var expireDays: UInt = 0 + + var body: some View { +#if DEBUG + let _ = Self._printChanges() + let _ = symLog.vlog() // just to get the # to compare it with .onAppear & onDisappear +#endif + let currencyField = CurrencyField(value: $centsToTransfer, currency: scopeInfo.currency) + + VStack(alignment: .leading, spacing: 6) { + CurrencyInputView(currencyField: currencyField, title: "Amount to receive:") + + HStack { + let disabled = centsToTransfer == 0 + + let deactivateAction = { + print("❗️ deactivateAction") + } + + NavigationLink(destination: LazyView { + ReceivePurpose(scopeInfo: scopeInfo, + centsToTransfer: centsToTransfer, + purpose: $purpose, + expireDays: $expireDays + ) { + deactivateAction() + } + .background(WalletColors().backgroundColor.edgesIgnoringSafeArea(.all)) + }) { + Text("title2") + } + .buttonStyle(TalerButtonStyle(type: .prominent)) + .disabled(disabled) + } + + Spacer() + } + .frame(maxWidth: .infinity, alignment: .leading) + .navigationTitle(navTitle) + .padding(.horizontal) + .onAppear { // make CurrencyField show the keyboard + DebugViewC.shared.setViewID(VIEW_INVOICE_P2P) + print("❗️Yikes \(navTitle) onAppear") + } + .onDisappear { + print("❗️Yikes \(navTitle) onDisappear") + } + } +} +// MARK: - +#if DEBUG +//struct ReceiveAmount_Previews: PreviewProvider { +// static var scopeInfo = ScopeInfo(type: ScopeInfo.ScopeInfoType.exchange, exchangeBaseUrl: DEMOEXCHANGE, currency: LONGCURRENCY) +// static var previews: some View { +// let amount = Amount(currency: LONGCURRENCY, integer: 10, fraction: 0) +// RequestPayment(exchangeBaseUrl: DEMOEXCHANGE, currency: LONGCURRENCY) +// } +//} +#endif diff --git a/TalerWallet1/Views/Peer2peer/SendAmount.swift b/TalerWallet1/Views/Peer2peer/SendAmount.swift new file mode 100644 index 0000000..e95aab9 --- /dev/null +++ b/TalerWallet1/Views/Peer2peer/SendAmount.swift @@ -0,0 +1,85 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +import SwiftUI +import taler_swift +import SymLog + +struct SendAmount: View { + private let symLog = SymLogV() + let navTitle = String(localized: "Send Coins") +// @ObservedObject private var keyboardResponder = KeyboardResponder() +// @FocusState private var isFocused: Bool + + let amountAvailable: Amount + let buttonFont: Font = .title2 + + @State private var centsToTransfer: UInt64 = 0 // TODO: maybe Decimal? + @State private var purpose: String = "" + @State private var expireDays: UInt = 0 + + var body: some View { + let currencyField = CurrencyField(value: $centsToTransfer, currency: amountAvailable.currencyStr) + + VStack(alignment: .leading, spacing: 6) { + CurrencyInputView(currencyField: currencyField, title: "Amount to send:") + + let available = amountAvailable.readableDescription + Text("Available: \(available)") + + Text("Choose where to send to:") + .padding(.top) + .font(.title3) + HStack { + let kbdShown: Bool = false // keyboardResponder.keyboardHeight > 0 + let title1 = kbdShown ? "To bank" : "To a bank\naccount" + let title2 = kbdShown ? "To wallet" : "To another\nwallet" + let disabled = centsToTransfer == 0 // TODO: check amountAvailable + + // Left button: To bank + NavigationLink(destination: LazyView { + Text("Bank pressed") + }) { + Text(title1) + } + .buttonStyle(TalerButtonStyle(type: .bordered)) + .disabled(disabled) + + NavigationLink(destination: LazyView { + SendPurpose(amountAvailable: amountAvailable, + centsToSend: centsToTransfer, + purpose: $purpose, + expireDays: $expireDays, + deactivateAction: {}) + .background(WalletColors().backgroundColor.edgesIgnoringSafeArea(.all)) + }) { + Text(title2) + } + .buttonStyle(TalerButtonStyle(type: .prominent)) + .disabled(disabled) + } + + Spacer() + } + .frame(maxWidth: .infinity, alignment: .leading) + .navigationTitle(navTitle) + .padding(.horizontal) + .onAppear { // make CurrencyField show the keyboard + DebugViewC.shared.setSheetID(SHEET_PAY_P2P) + print("❗️Yikes SendAmount onAppear") + } + .onDisappear { + print("❗️Yikes SendAmount onDisappear") + } + } +} +// MARK: - +#if DEBUG +struct SendAmount_Previews: PreviewProvider { + static var previews: some View { + let amount = Amount(currency: "TaLeR", integer: 10, fraction: 0) + SendAmount(amountAvailable: amount) + } +} +#endif diff --git a/TalerWallet1/Views/Peer2peer/SendNow.swift b/TalerWallet1/Views/Peer2peer/SendNow.swift new file mode 100644 index 0000000..8c81271 --- /dev/null +++ b/TalerWallet1/Views/Peer2peer/SendNow.swift @@ -0,0 +1,87 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +import SwiftUI +import taler_swift +import SymLog + + +struct SendNow: View { + private let symLog = SymLogV() + + var amountToSend: Amount + var purpose: String + var expireDays: UInt + + @ObservedObject var model: Peer2peerModel + @State var peerPushResponse: PeerPushResponse? + + @State var talerURI: String? = nil + + var body: some View { + ScrollView { + if talerURI == nil { + LoadingView(backButtonHidden: true) + } else { + VStack() { + Text("Let the payee scan this QR code to receive:") + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 30) + .font(.title3) + + QRGeneratorView(text: talerURI!) + + Text("Alternatively, copy and send this URI:") + .fixedSize(horizontal: false, vertical: true) + .font(.title3) + .padding(.vertical) + + Text(talerURI!) + .padding(.bottom) + + CopyShare(textToCopy: talerURI!, dismissFirst: true) + .disabled(false) + + Text("The QR code can also be copied and shared from Transactions later") + .fixedSize(horizontal: false, vertical: true) + .font(.subheadline) + .padding(.vertical, 20) + + Button("Done") { + dismissTop() + } + .buttonStyle(TalerButtonStyle(type: .prominent)) + .padding(.vertical) + + } + .interactiveDismissDisabled() // can only use "Done" button to dismiss + .navigationBarHidden(true) // no back button, no title + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .padding(.horizontal) + } + } + .task { + symLog.log(".task") + do { + // generate talerURI + let timestamp = Timestamp.inSomeDays(expireDays) + let terms = PeerContractTerms(amount: amountToSend, summary: purpose, purse_expiration: timestamp) + let baseURL = DEMOEXCHANGE // TODO: use correct baseURL + peerPushResponse = try await model.initiatePeerPushDebitM(baseURL, terms: terms) + talerURI = peerPushResponse?.talerUri + } catch { // TODO: error + symLog.log(error.localizedDescription) + } + } // task + } +} +// MARK: - +//struct SendNow_Previews: PreviewProvider { +// static var previews: some View { +// Group { +// SendNow() +// SendNow(talerURI: "taler://pay-push/exchange.demo.taler.net") +// } +// } +//} diff --git a/TalerWallet1/Views/Peer2peer/SendPurpose.swift b/TalerWallet1/Views/Peer2peer/SendPurpose.swift new file mode 100644 index 0000000..cabeea8 --- /dev/null +++ b/TalerWallet1/Views/Peer2peer/SendPurpose.swift @@ -0,0 +1,126 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +import SwiftUI +import taler_swift +import SymLog + +struct SendPurpose: View { + private let symLog = SymLogV() + @FocusState private var isFocused: Bool + @StateObject var model = Peer2peerModel.model() + @State var peerPushCheck: CheckPeerPushDebitResponse? + + var amountAvailable: Amount + var centsToSend: UInt64 + @Binding var purpose: String + @Binding var expireDays: UInt + var deactivateAction: () -> Void + + let formatter = CurrencyFormatter.shared // TODO: based on currency + + let buttonFont: Font = .title2 + private var label: String { + let mag = pow(10, formatter.maximumFractionDigits) + return formatter.string(for: Decimal(centsToSend) / mag) ?? "" + } + + private func fee(ppCheck: CheckPeerPushDebitResponse?) -> String { + do { + if let p2pcheck = ppCheck { + let fee = try p2pcheck.amountEffective - p2pcheck.amountRaw + return fee.readableDescription + } + } catch {} + return "" + } + + var body: some View { + let amount = Amount.amountFromCents(amountAvailable.currencyStr, centsToSend) + + let fee = fee(ppCheck: peerPushCheck) + VStack (spacing: 6) { + Text(amount.readableDescription) + Text("+ \(fee) payment fee") + .foregroundColor(.red) + VStack(alignment: .leading, spacing: 6) { + Text("Purpose:") + .padding(.top) + .font(.title3) + + TextField("Purpose", text: $purpose) + .font(.title) + .foregroundColor(WalletColors().fieldForeground) // text color + .background(WalletColors().fieldBackground) + .border(.primary) + .focused($isFocused) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.4) { + isFocused = true // make first responder - raise keybord + } + } + + HStack{ + Spacer() + Text("\(purpose.count)/100") + } // maximum 100 characters + + Text("Expires in:") + .font(.title3) + + // TODO: compute max Expiration day from peerPushCheck to disable 30 (and even 7) + SelectDays(selected: $expireDays, maxExpiration: 35) + .disabled(false) + .padding(.bottom) + + let buttonTitle = "Send \(label) \(amountAvailable.currencyStr) now" + let disabled = (expireDays == 0) || (purpose.count < 1) // TODO: check amountAvailable + + NavigationLink(destination: LazyView { + SendNow(amountToSend: amount, purpose: purpose, expireDays: expireDays, model: model) + .background(WalletColors().backgroundColor.edgesIgnoringSafeArea(.all)) + }) { + Text(buttonTitle) + .font(buttonFont) + } + .buttonStyle(TalerButtonStyle(type: .prominent)) + .disabled(disabled) + + Spacer() + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal) + } + .navigationTitle("To another Wallet") + .onAppear { + DebugViewC.shared.setSheetID(VIEW_SEND_PURPOSE) + print("❗️ SendPurpose onAppear") + } + .onDisappear { + print("❗️ SendPurpose onDisappear") + deactivateAction() + } + .task { + symLog.log(".task") + do { + peerPushCheck = try await model.checkPeerPushDebitM(amount) + } catch { // TODO: error + symLog.log(error.localizedDescription) + } + } + } + +} +// MARK: - +struct SendPurpose_Previews: PreviewProvider { + static var previews: some View { + @State var purpose: String = "" + @State var expireDays: UInt = 0 + let amount = Amount(currency: "TaLeR", integer: 10, fraction: 0) + SendPurpose(amountAvailable: amount, centsToSend: 5, + purpose: $purpose, expireDays: $expireDays) { + print("deactivateAction") + } + } +} diff --git a/TalerWallet1/Views/Pending/PendingModel.swift b/TalerWallet1/Views/Pending/PendingModel.swift deleted file mode 100644 index 6b0e38e..0000000 --- a/TalerWallet1/Views/Pending/PendingModel.swift +++ /dev/null @@ -1,82 +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 -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/PendingOpsListView.swift b/TalerWallet1/Views/Pending/PendingOpsListView.swift deleted file mode 100644 index a4d9e61..0000000 --- a/TalerWallet1/Views/Pending/PendingOpsListView.swift +++ /dev/null @@ -1,75 +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 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") - do { - try await reloadAction() - } catch { - // TODO: show error - symLog.log(error.localizedDescription) - } - } - } -} -// 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 { - do { - symLog?.log("refreshing") - try await reloadAction() - } catch { - // TODO: error - symLog?.log(error.localizedDescription) - } - } - } - } - } -} diff --git a/TalerWallet1/Views/Pending/PendingOpView.swift b/TalerWallet1/Views/Settings/Pending/PendingOpView.swift index 12c2924..16a9aab 100644 --- a/TalerWallet1/Views/Pending/PendingOpView.swift +++ b/TalerWallet1/Views/Settings/Pending/PendingOpView.swift @@ -1,17 +1,6 @@ /* - * 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/> + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md */ import SwiftUI import taler_swift @@ -24,7 +13,9 @@ struct PendingOpView: View { var body: some View { Section { - Text(pendingOp.exchangeBaseUrl) + if let baseURL = pendingOp.exchangeBaseUrl { + Text(baseURL) + } Text(pendingOp.id) .font(.caption) Toggle("isLongPolling", isOn: $polling) @@ -47,18 +38,20 @@ struct PendingOpView: View { } } } - +// MARK: - +#if DEBUG 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/", + exchangeBaseUrl: DEMOEXCHANGE, + id: "exchange-update:" + DEMOEXCHANGE, isLongpolling: false, givesLifeness: true, isDue: false, - timestampDue: Timestamp(from:1669931055000)) + timestampDue: Timestamp(from:1700000000000)) static var previews: some View { - Form { + List { PendingOpView(pendingOp: pending1) } } } +#endif diff --git a/TalerWallet1/Views/Settings/Pending/PendingOpsListView.swift b/TalerWallet1/Views/Settings/Pending/PendingOpsListView.swift new file mode 100644 index 0000000..3a35398 --- /dev/null +++ b/TalerWallet1/Views/Settings/Pending/PendingOpsListView.swift @@ -0,0 +1,64 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +import SwiftUI +import SymLog + +struct PendingOpsListView: View { + private let symLog = SymLogV(0) + let navTitle = String(localized: "Pending") + + @ObservedObject var model: PendingModel + + var body: some View { +#if DEBUG + let _ = Self._printChanges() + let _ = symLog.vlog() // just to get the # to compare it with .onAppear & onDisappear +#endif + let reloadAction = model.updateM + Content(symLog: symLog, model: model, reloadAction: reloadAction) + .navigationTitle(navTitle) + .task { + symLog.log(".task") + do { + try await reloadAction() + } catch { // TODO: show error + symLog.log(error.localizedDescription) + } + } + } +} +// MARK: - +extension PendingOpsListView { + struct Content: View { + let symLog: SymLogV? + @ObservedObject var model: PendingModel + var reloadAction: () async throws -> () + + var body: some View { +#if DEBUG + let _ = Self._printChanges() + let _ = symLog?.vlog() // just to get the # to compare it with .onAppear & onDisappear +#endif + ScrollViewReader { scrollView in + List(model.pendingOperations, id: \.self) { pendingOp in + PendingOpView(pendingOp: pendingOp) + } + .listStyle(SidebarListStyle()) + .navigationBarTitleDisplayMode(.large) + .onAppear() { + DebugViewC.shared.setViewID(VIEW_PENDING) + } + .refreshable { + do { + symLog?.log("refreshing") + try await reloadAction() + } catch { // TODO: error + symLog?.log(error.localizedDescription) + } + } + } + } + } +} diff --git a/TalerWallet1/Views/Settings/SettingsItem.swift b/TalerWallet1/Views/Settings/SettingsItem.swift index 7735c97..b8443fe 100644 --- a/TalerWallet1/Views/Settings/SettingsItem.swift +++ b/TalerWallet1/Views/Settings/SettingsItem.swift @@ -1,19 +1,7 @@ /* - * 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/> + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md */ - import SwiftUI struct SettingsItem<Content: View>: View { @@ -44,16 +32,21 @@ struct SettingsItem<Content: View>: View { }.padding([.bottom], 4) } } - +// MARK: - struct SettingsToggle: View { var name: String @Binding var value: Bool var description: String? + var action: () -> Void = {} var body: some View { VStack { - Toggle(name, isOn: $value.animation(.spring())) + Toggle(name, isOn: $value.animation()) .font(.title2) + .onChange(of: value) { value in + action() + } + if let desc = description { Text(desc) .frame(maxWidth: .infinity, alignment: .leading) @@ -62,9 +55,8 @@ struct SettingsToggle: View { }.padding([.bottom], 4) } } - - - +// MARK: - +#if DEBUG struct SettingsItemPreview : View { @State var developerMode: Bool = false @@ -78,9 +70,7 @@ struct SettingsItemPreview : View { struct SettingsItem_Previews: PreviewProvider { static var previews: some View { List { - NavigationLink { } label: { - SettingsItem (name: "Exchanges", description: "Manage list of exchanges known to this wallet") {} - } + SettingsItem (name: "Exchanges", description: "Manage list of exchanges known to this wallet") {} SettingsItemPreview() SettingsItem(name: "Save Logfile", description: "Help debugging wallet-core") { Button("Save") { @@ -91,3 +81,4 @@ struct SettingsItem_Previews: PreviewProvider { } } } +#endif diff --git a/TalerWallet1/Views/Settings/SettingsView.swift b/TalerWallet1/Views/Settings/SettingsView.swift index b84b242..db63b5a 100644 --- a/TalerWallet1/Views/Settings/SettingsView.swift +++ b/TalerWallet1/Views/Settings/SettingsView.swift @@ -1,17 +1,6 @@ /* - * 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/> + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md */ import SwiftUI import taler_swift @@ -21,115 +10,173 @@ 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 + private let symLog = SymLogV() + let navTitle = String(localized: "Settings") + @AppStorage("developerMode") var developerMode: Bool = false @AppStorage("developDelay") var developDelay: Bool = false + @AppStorage("listStyle") var myListStyle = MyListStyle.automatic - var showSidebar: () -> Void - init(showSidebar: @escaping () -> Void) { - self.showSidebar = showSidebar - } + var hamburgerAction: () -> Void @State private var checkDisabled = false @State private var withDrawDisabled = false +#if DEBUG + @State private var diagnosticModeEnabled = true +#else + @State private var diagnosticModeEnabled = UserDefaults.standard.bool(forKey: "diagnostic_mode_enabled") +#endif + @State private var showDevelopItems = false var body: some View { - symLog { NavigationView { +#if DEBUG + let _ = Self._printChanges() + let _ = symLog.vlog() // just to get the # to compare it with .onAppear & onDisappear +#endif + let walletCore = WalletCore.shared + Group { List { - NavigationLink { - ExchangeListView(viewModel: controller.exchangeModel) - } label: { - SettingsItem(name: "Exchanges", description: "Manage list of exchanges known to this wallet") {} + HStack { + Text("Liststyle:") + .font(.title2) + Spacer() + Picker(selection: $myListStyle) { + ForEach(MyListStyle.allCases, id: \.self) { + Text($0.displayName).tag($0) + .font(.title2) + } + } label: {} + .pickerStyle(.menu) +// .frame(alignment: .trailing) +// .background(WalletColors().buttonBackColor(pressed: false, disabled: false)) TODO: RoundRect } - 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 - }) + if diagnosticModeEnabled { + SettingsToggle(name: String(localized: "Developer Mode"), value: $developerMode, + description: String(localized: "More information intended for debugging")) { + DebugViewC.shared.setViewID(VIEW_SETTINGS) + withAnimation { showDevelopItems = developerMode } + } + if showDevelopItems { // show or hide the following items + NavigationLink { // whole row like in a tableView + LazyView { PendingOpsListView(model: PendingModel.model()) } + } label: { + SettingsItem(name: String(localized: "Pending Operations"), description: String(localized: "Exchange not yet ready...")) {} + } + SettingsToggle(name: String(localized: "Set 2 seconds delay"), value: $developDelay, + description: String(localized: "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 - Task { - let testModel: ExchangeTestModel = ExchangeTestModel(walletCore: walletCore) - symLog.log("Withdrawing") - do { - try await testModel.loadTestKudos() - } catch { - // TODO: show error - symLog.log(error.localizedDescription) + SettingsItem(name: String(localized: "Withdraw \(DEMOCURRENCY)"), description: String(localized: "Get money for testing")) { + Button("Withdraw") { + withDrawDisabled = true // don't run twice + Task { + let model: SettingsModel = SettingsModel() + symLog.log("Withdraw TestKUDOS") + do { + try await model.loadTestKudosM() + } catch { // TODO: show error + symLog.log(error.localizedDescription) + } } } + .buttonStyle(.bordered) + .disabled(withDrawDisabled) } - .buttonStyle(.bordered) - .disabled(withDrawDisabled) - } - SettingsItem(name: "Run Integration Test", description: "Check if wallet-core works") { - Button("Check") { - checkDisabled = true // don't run twice - Task { - let testModel: ExchangeTestModel = ExchangeTestModel(walletCore: walletCore) - symLog.log("running integration test") - do { - try await testModel.runIntegrationTest() - } catch { - // TODO: show error - symLog.log(error.localizedDescription) + SettingsItem(name: String(localized: "Run Integration Test"), + description: String(localized: "Perform basic test transactions")) { + Button("Test 1") { + checkDisabled = true // don't run twice + Task { + let model: SettingsModel = SettingsModel() + symLog.log("running integration test") + do { + try await model.runIntegrationTestM(newVersion: false) + } catch { // TODO: show error + symLog.log(error.localizedDescription) + } } } + .buttonStyle(.bordered) + .disabled(checkDisabled) } - .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: String(localized: "Run Integration Test V2"), + description: String(localized: "Perform more test transactions")) { + Button("Test 2") { + checkDisabled = true // don't run twice + Task { + let model: SettingsModel = SettingsModel() + symLog.log("running integration test V2") + do { + try await model.runIntegrationTestM(newVersion: true) + } catch { // TODO: show error + symLog.log(error.localizedDescription) + } + } + } + .buttonStyle(.bordered) + .disabled(checkDisabled) } - SettingsItem(name: "Used Bank") { - Text("\(walletCore.versionInfo!.bank)") + SettingsItem(name: String(localized: "Save Logfile"), + description: String(localized: "Help debugging wallet-core")) { + Button("Save") { + symLog.log("Saving Log") + // FIXME: Save Logfile + } + .buttonStyle(.bordered) + .disabled(true) } } } + + VStack { + SettingsItem(name: String(localized: "App Version")) { + Text("\(Bundle.main.releaseVersionNumberPretty)") + } + SettingsItem(name: String(localized: "Wallet Core Version")) { + Text("\(walletCore.versionInfo!.version)") + } + SettingsItem(name: String(localized: "Wallet Core DevMode")) { + Text("\(walletCore.versionInfo!.devMode ? "YES" : "NO")") + } + SettingsItem(name: String(localized: "Supported Exchange Versions")) { + Text("\(walletCore.versionInfo!.exchange)") + } + SettingsItem(name: String(localized: "Supported Merchant Versions")) { + Text("\(walletCore.versionInfo!.merchant)") + } + SettingsItem(name: String(localized: "Used Bank")) { + Text("\(walletCore.versionInfo!.bank)") + } + } } - .navigationTitle("Settings") - .navigationBarItems(leading: HamburgerButton(action: showSidebar)) - } } // symLog + .listStyle(myListStyle.style) + .anyView + } + .navigationTitle(navTitle) + .navigationBarItems(leading: HamburgerButton(action: hamburgerAction)) + .onAppear() { + showDevelopItems = developerMode + DebugViewC.shared.setViewID(VIEW_SETTINGS) + } +#if !DEBUG + .onReceive( + NotificationCenter.default + .publisher(for: UserDefaults.didChangeNotification) + .receive(on: RunLoop.main) + ) { _ in // user changed Diagnostic Mode in iOS Settings.app + withAnimation { diagnosticModeEnabled = UserDefaults.standard.bool(forKey: "diagnostic_mode_enabled") } + } +#endif } } extension Bundle { @@ -144,10 +191,10 @@ extension Bundle { } } -struct SettingsView_Previews: PreviewProvider { - static var previews: some View { - SettingsView { - - } - } -} +//struct SettingsView_Previews: PreviewProvider { +// static var previews: some View { +// SettingsView { +// +// } +// } +//} diff --git a/TalerWallet1/Views/Sheets/QRSheet.swift b/TalerWallet1/Views/Sheets/QRSheet.swift new file mode 100644 index 0000000..6385ec4 --- /dev/null +++ b/TalerWallet1/Views/Sheets/QRSheet.swift @@ -0,0 +1,52 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +import SwiftUI +import CodeScanner +import SymLog +import AVFoundation + +struct QRSheet: View { + private let symLog = SymLogV() + @State private var scannedCode: String? + + var body: some View { + Group { + if scannedCode != nil { + let _ = print(scannedCode as Any) // TODO: logging + + if let scannedURL = URL(string: scannedCode!) { + let scheme = scannedURL.scheme + if scheme == "taler" { + URLSheet(urlToOpen: scannedURL) + } else { + let _ = print(scannedURL) // TODO: logging + ErrorView(errortext: scannedURL.absoluteString) + } + } else { + ErrorView(errortext: scannedCode) + } + } else { + CodeScannerView(codeTypes: [AVMetadataObject.ObjectType.qr], showViewfinder: true) { response in + switch response { + case .success(let result): + symLog.log("Found code: \(result.string)") + scannedCode = result.string + case .failure(let error): + ErrorView(errortext: error.localizedDescription) + } + } + // TODO: errorAlert + } + } + } + +} +// 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/Sheets/ShareSheet.swift b/TalerWallet1/Views/Sheets/ShareSheet.swift new file mode 100644 index 0000000..e56148f --- /dev/null +++ b/TalerWallet1/Views/Sheets/ShareSheet.swift @@ -0,0 +1,40 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +import Foundation +import UIKit +import SymLog + +// You can control the appearance of the link by providing view content. +// For example, you can use a Label to display a link with a custom icon: +// ShareLink(item: URL(string: "https://developer.apple.com/xcode/swiftui/")!) { +// Label("Share", image: "MyCustomShareIcon") +// } +// If you only wish to customize the link’s title, you can use one of the convenience +// initializers that takes a string and creates a Label for you: +// ShareLink("Share URL", item: URL(string: "https://developer.apple.com/xcode/swiftui/")!) +// The link can share any content that is Transferable. +// Many framework types, like URL, already conform to this protocol. + + +public class ShareSheet: ObservableObject { + private let symLog = SymLogC() + + static func shareSheet(url: String) { + let url = URL(string: url) + let activityView = UIActivityViewController(activityItems: [url!], applicationActivities: nil) + + let allScenes = UIApplication.shared.connectedScenes + let scene = allScenes.first { $0.activationState == .foregroundActive } + + if let windowScene = scene as? UIWindowScene { + windowScene.keyWindow?.rootViewController?.present(activityView, animated: true, completion: nil) + } + } + + init() { + symLog.log("init") + + } +} diff --git a/TalerWallet1/Views/Sheets/Sheet.swift b/TalerWallet1/Views/Sheets/Sheet.swift new file mode 100644 index 0000000..e2e8a9a --- /dev/null +++ b/TalerWallet1/Views/Sheets/Sheet.swift @@ -0,0 +1,44 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +import SwiftUI +import SymLog + +struct Sheet: View { + private let symLog = SymLogV() + @Environment(\.dismiss) var dismiss // call dismiss() to get rid of the sheet + @EnvironmentObject private var debugViewC: DebugViewC + + var sheetView: AnyView + + var cancelButton: some View { + Button("Cancel") { + print(dismiss) // TODO: delete this line + dismissTop() + } + } + + var body: some View { + let idString = debugViewC.sheetID > 0 ? String(debugViewC.sheetID) : "" + NavigationView { + sheetView + .navigationBarItems(leading: cancelButton) + .background(WalletColors().backgroundColor.edgesIgnoringSafeArea(.all)) + } + .overlay(alignment: .top) { + // Show the viewID on top of the sheet's NavigationView + Text(idString) + .font(.caption2) + .foregroundColor(.purple) + .edgesIgnoringSafeArea(.top) + .id("sheetID") + } + } +} +// MARK: - +//struct Sheet_Previews: PreviewProvider { +// static var previews: some View { +// +// } +//} diff --git a/TalerWallet1/Views/Sheets/URLSheet.swift b/TalerWallet1/Views/Sheets/URLSheet.swift new file mode 100644 index 0000000..342447b --- /dev/null +++ b/TalerWallet1/Views/Sheets/URLSheet.swift @@ -0,0 +1,45 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +import SwiftUI +import SymLog + +struct URLSheet: View { + private let symLog = SymLogV() + let navTitle = String(localized: "Invalid URL") + var urlToOpen: URL + @EnvironmentObject private var controller: Controller + + @State private var urlCommand: UrlCommand? = nil + + var body: some View { + Group { + if urlCommand == UrlCommand.withdraw { + let model = WithdrawModel.model(baseURL: "global") // TODO: get baseURL from command + WithdrawURIView(url: urlToOpen, model: model) + } else if urlCommand == UrlCommand.pay { + let model = PaymentURIModel.model() + PaymentURIView(url: urlToOpen, model: model) + } else { + VStack { // Error view + Spacer() + Text(controller.messageForSheet ?? urlToOpen.absoluteString) + .font(.title) + Spacer() + Spacer() + } + .navigationTitle(navTitle) + } + }.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/Transactions/ManualDetails.swift b/TalerWallet1/Views/Transactions/ManualDetails.swift new file mode 100644 index 0000000..4940e18 --- /dev/null +++ b/TalerWallet1/Views/Transactions/ManualDetails.swift @@ -0,0 +1,69 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +import SwiftUI +import taler_swift + +struct ManualDetails: View { + var common : TransactionCommon + var details : WithdrawalDetails + var body: some View { + if let paytoUris = details.exchangePaytoUris { + let payto = paytoUris[0] + let payURL = URL(string: payto) + let iban = payURL?.iban ?? "unknown IBAN" + let amount = common.amountRaw.readableDescription + Text("Make a wire transfer of \(amount) to:") + .listRowSeparator(.hidden) + HStack { + Text(iban) + Spacer() + CopyButton(textToCopy: iban, vertical: true) + .disabled(false) + } .padding(.leading) + .padding(.vertical, -8) + .listRowSeparator(.hidden) + Text("and use the transaction subject:") + .listRowSeparator(.hidden) + HStack { + Text(details.reservePub) + Spacer() + CopyButton(textToCopy: details.reservePub, vertical: true) + .disabled(false) + } .padding(.leading) + .padding(.vertical, -8) + .listRowSeparator(.hidden) + HStack { + Spacer() + ShareButton(textToShare: payto, dismissFirst: false) + .disabled(false) + Spacer() + } .listRowSeparator(.hidden) + Text("Payto URL") + .font(.footnote) + .foregroundColor(Color.yellow) // clear + .padding(.vertical, -8) + .listRowSeparator(.automatic) + } + } +} +// MARK: - +#if DEBUG +struct ManualDetails_Previews: PreviewProvider { + static var previews: some View { + let common = TransactionCommon(type: .withdrawal, + txState: TransactionState(major: .done), + amountEffective: try! Amount(fromString: LONGCURRENCY + ":1.1"), + amountRaw: try! Amount(fromString: LONGCURRENCY + ":2.2"), + transactionId: "someTxID", + timestamp: Timestamp(from: 1_666_666_000_000), + txActions: []) + let details = WithdrawalDetails(type: .manual, reservePub: "ReSeRvEpUbLiC_KeY_FoR_WiThDrAwAl", reserveIsReady: false, + exchangePaytoUris:["payto://iban/SANDBOXX/DE159593?receiver-name=Exchange+Company"]) + List { + ManualDetails(common: common, details: details) + } + } +} +#endif diff --git a/TalerWallet1/Views/Transactions/ThreeAmounts.swift b/TalerWallet1/Views/Transactions/ThreeAmounts.swift new file mode 100644 index 0000000..d016089 --- /dev/null +++ b/TalerWallet1/Views/Transactions/ThreeAmounts.swift @@ -0,0 +1,109 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +import SwiftUI +import taler_swift + +struct ThreeAmounts: View { + var common: TransactionCommon + var topTitle: String + var bottomTitle: String? + let baseURL: String? + let large: Bool // set to false for QR or IBAN + var body: some View { + let raw = common.amountRaw + let effective = common.amountEffective + let fee = common.fee() + let incoming = common.incoming() + let pending = (common.txState.major == TransactionMajorState.pending) + + let defaultBottomTitle = incoming ? (pending ? "Pending coins to obtain:" : "Obtained coins:") + : "Paid coins:" + ThreeAmountsView(topTitle: topTitle, topAmount: raw, fee: fee, + bottomTitle: bottomTitle ?? defaultBottomTitle, bottomAmount: effective, + large: large, pending: pending, incoming: incoming, + baseURL: baseURL, + status: common.txState.major.rawValue) + } +} +// MARK: - +struct ThreeAmountsView: View { + var topTitle: String + var topAmount: Amount + var fee: Amount + var bottomTitle: String + var bottomAmount: Amount + let large: Bool + let pending: Bool + let incoming: Bool + let baseURL: String? + var status: String? + + var body: some View { + let labelColor = Color(UIColor.label) + let foreColor = pending ? WalletColors().pendingColor(incoming) + : WalletColors().transactionColor(incoming) + let feeColor = WalletColors().transactionColor(false) + let feeSign = incoming ? "-" : "+" + + VStack { + AmountView(title: topTitle, + value: topAmount.readableDescription, + color: labelColor, + large: large) + .padding(.bottom, 4) + AmountView(title: "Exchange fee:", + value: feeSign + fee.readableDescription, + color: fee.isZero ? labelColor : feeColor, + large: false) + .padding(.bottom, 4) + AmountView(title: bottomTitle, + value: bottomAmount.readableDescription, + color: foreColor, + large: large) + if let baseURL { + VStack { + Text(incoming ? "from Exchange" : "to Exchange") + .font(.title3) + Text(baseURL.trimURL()) + .font(large ? .title : .title2) + .fontWeight(large ? .medium : .regular) + .foregroundColor(labelColor) + } + .padding(.top, 4) + .frame(maxWidth: .infinity, alignment: .center) + .listRowSeparator(.hidden) + + } + if let status { + HStack { + Spacer() + Text("Status: \(status)") // TODO: localize + .font(.title2) + }.padding() + } + } + } +} +// MARK: - +struct ThreeAmounts_Previews: PreviewProvider { + static var previews: some View { + let common = TransactionCommon(type: .withdrawal, + txState: TransactionState(major: .done), + amountEffective: try! Amount(fromString: LONGCURRENCY + ":0.1"), + amountRaw: try! Amount(fromString: LONGCURRENCY + ":0.2"), + transactionId: "someTxID", + timestamp: Timestamp(from: 1_666_666_000_000), + txActions: []) + Group { + List { + ThreeAmounts(common: common, topTitle: "Withdrawal", baseURL: DEMOEXCHANGE, large: 1==1) + .safeAreaInset(edge: .bottom) { + ProminentButton(title: "Accept", action: {}) + .padding(.horizontal) + } + } + } + } +} diff --git a/TalerWallet1/Views/Transactions/TransactionDetail.swift b/TalerWallet1/Views/Transactions/TransactionDetail.swift deleted file mode 100644 index 6e74f0e..0000000 --- a/TalerWallet1/Views/Transactions/TransactionDetail.swift +++ /dev/null @@ -1,136 +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 TransactionDetail: View { - var transaction : Transaction - - var body: some View { - let common = transaction.common() - let dateString = TalerDater.dateString(from: common.timestamp) - - VStack() { - Spacer() - Text("\(common.type)") // TODO: translation - .font(.title) - .fontWeight(.medium) - .padding(.bottom) - Spacer() - Text("\(dateString)") - .font(.title2) - .padding(.vertical) - switch transaction { - case .withdrawal(let withdrawalTransaction): - details(transaction: transaction) - threeAmounts(common: common, topTitle: "Chosen amount to withdraw:", bottomTitle: "Obtained coins:", incoming: true) - case .payment(let paymentTransaction): - let details = paymentTransaction.details - let info = details.info - Text("Status: \(details.status)") - .font(.title2) - .padding(.bottom) - Text(info.summary) - .font(.title) - .lineLimit(4) - .padding(.bottom) - threeAmounts(common: common, topTitle: "Sum to be paid:", bottomTitle: "Paid coins:", incoming: false) - case .refund(let refundTransaction): - threeAmounts(common: common, topTitle: "Refunded amount:", bottomTitle: "Obtained coins:", incoming: true) - case .tip(let tipTransaction): - threeAmounts(common: common, topTitle: "Tip to be paid:", bottomTitle: "Paid coins:", incoming: false) - case .refresh(let refreshTransaction): - threeAmounts(common: common, topTitle: "Refreshed amount:", bottomTitle: "Paid coins:", incoming: false) - } - Spacer() - Button(role: .destructive, action: { - // TODO: delete from wallet-core - print("Should delete \(common.transactionId)") - }, label: { - HStack { - Text("Delete from list" + " ") - Image(systemName: "trash") - } - .font(.title) - .frame(maxWidth: .infinity) - }) - .buttonStyle(.bordered) - .controlSize(.large) - } - } -} - -extension TransactionDetail { - struct threeAmounts: View { - var common: TransactionCommon - var topTitle: String - var bottomTitle: String - var incoming: Bool - - var body: some View { - let raw = common.amountRaw - let effective = common.amountEffective - let fee = common.fee() - let labelColor = Color(UIColor.label) - let outColor = Color("Outgoing") - let inColor = Color("Incoming") - - AmountView(title: topTitle, - value: raw.readableDescription, - color: labelColor) - .padding(.bottom) - AmountView(title: "Exchange fee:", - value: fee.readableDescription, - color: fee.isZero ? labelColor : outColor) - .padding(.bottom) - AmountView(title: bottomTitle, - value: effective.readableDescription, - color: incoming ? inColor : outColor) - .padding(.bottom) - } - } - struct details: View { - var transaction : Transaction - var body: some View { - let details = transaction.detailsToShow() - let keys = details.keys - if keys.contains(EXCHANGEBASEURL) { - if let baseURL = details[EXCHANGEBASEURL] { - Text("from \(baseURL.trimURL())") - .font(.title2) - .padding(.bottom) - } - } - } - } -} - -#if DEBUG -struct TransactionDetail_Previews: PreviewProvider { - static var withdrawal = Transaction(incoming: true, - id: "some withdrawal ID", - time: Timestamp(from: 1_666_000_000_000)) - static var payment = Transaction(incoming: false, - id: "some payment ID", - time: Timestamp(from: 1_666_666_000_000)) - static var previews: some View { - Group { - TransactionDetail(transaction: withdrawal) - TransactionDetail(transaction: payment) - } - } -} -#endif diff --git a/TalerWallet1/Views/Transactions/TransactionDetailView.swift b/TalerWallet1/Views/Transactions/TransactionDetailView.swift new file mode 100644 index 0000000..7de0960 --- /dev/null +++ b/TalerWallet1/Views/Transactions/TransactionDetailView.swift @@ -0,0 +1,255 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +import SwiftUI +import taler_swift +import SymLog + +struct TransactionDetailView: View { + private let symLog = SymLogV() + @AppStorage("listStyle") var myListStyle = MyListStyle.automatic + + var transaction: Transaction + var deleteAction: ((_ transactionId: String) async throws -> Void)? + var abortAction: ((_ transactionId: String) async throws -> Void)? + var doneAction: (() -> Void)? + + var body: some View { +#if DEBUG + let _ = Self._printChanges() + let _ = symLog.vlog() // just to get the # to compare it with .onAppear & onDisappear +#endif + let common = transaction.common + let pending = transaction.isPending + let dateString = TalerDater.dateString(from: common.timestamp) + let navTitle = (pending ? String(localized: "Pending ") : "") + "\(common.type)" // TODO: localize type + + Group { + List { + Text("\(dateString)") + .font(.title2) +// .listRowSeparator(.hidden) + SwitchCase(transaction: transaction, common: common) + + if transaction.isAbortable { if let abortAction { + AbortButton(common: common, abortAction: abortAction) + } } + if transaction.isDeleteable { if let deleteAction { + DeleteButton(common: common, deleteAction: deleteAction) + } } + + if let doneAction { + DoneButton(doneAction: doneAction) + .onNotification(.TransactionStateTransition) { notification in +print(notification.userInfo) + if let transition = notification.userInfo?[TRANSACTIONTRANSITION] as? TransactionTransition { +print(transition.newTxState.major) + if transition.transactionId == common.transactionId { + doneAction() + } + } + } + } +// if transaction.isSuspendable { if let suspendAction { +// SuspendButton(common: common, suspendAction: suspendAction) +// } } +// if transaction.isResumable { if let resumeAction { +// ResumeButton(common: common, resumeAction: resumeAction) +// } } + } + .listStyle(myListStyle.style) + .anyView + } + .navigationTitle(navTitle) + .onAppear { + symLog.log("onAppear") + DebugViewC.shared.setViewID(VIEW_TRANSACTIONDETAIL) + } + .onDisappear { + symLog.log("onDisappear") + } + } +//} +// +//extension TransactionDetail { + struct SwitchCase: View { + let transaction: Transaction + let common: TransactionCommon + + var body: some View { + let pending = (common.txState.major == TransactionMajorState.pending) + switch transaction { + case .withdrawal(let withdrawalTransaction): + let details = withdrawalTransaction.details + if pending { + switch details.withdrawalDetails.type { + case .manual: + ManualDetails(common: common, details: details.withdrawalDetails) + + case .bankIntegrated: + QRCodeDetails(transaction: transaction) + } + } + ThreeAmounts(common: common, topTitle: String(localized: "Chosen amount to withdraw:"), + baseURL: withdrawalTransaction.details.exchangeBaseUrl, large: true) + case .payment(let paymentTransaction): + let details = paymentTransaction.details + let info = details.info + Text(info.summary) + .font(.title) + .lineLimit(4) + .padding(.bottom) + ThreeAmounts(common: common, topTitle: String(localized: "Sum to be paid:"), + baseURL: nil, large: true) // TODO: baseURL + case .refund(let refundTransaction): + let details = refundTransaction.details // TODO: more details + ThreeAmounts(common: common, topTitle: String(localized: "Refunded amount:"), + baseURL: nil, large: true) // TODO: baseURL + case .reward(let rewardTransaction): + let details = rewardTransaction.details // TODO: more details + ThreeAmounts(common: common, topTitle: String(localized: "Received Reward:"), + baseURL: details.exchangeBaseUrl, large: true) +// case .tip(let tipTransaction): +// let details = tipTransaction.details // TODO: details +// ThreeAmounts(common: common, topTitle: String(localized: "Received Tip:"), large: true) + case .refresh(let refreshTransaction): + let details = refreshTransaction.details // TODO: details + ThreeAmounts(common: common, topTitle: String(localized: "Refreshed amount:"), + baseURL: nil, large: true) // TODO: baseURL + case .peer2peer(let p2pTransaction): + let details = p2pTransaction.details // TODO: details + QRCodeDetails(transaction: transaction) + ThreeAmounts(common: common, topTitle: String(localized: "Peer to Peer:"), + baseURL: details.exchangeBaseUrl, large: false) + } + } + } + struct QRCodeDetails: View { + var transaction : Transaction + var body: some View { + let details = transaction.detailsToShow() + let keys = details.keys + if keys.contains(TALERURI) { + if let talerURI = details[TALERURI] { + if talerURI.count > 10 { + VStack { + QRGeneratorView(text: talerURI) + Text(talerURI) + } + } + } + } else if keys.contains(EXCHANGEBASEURL) { + if let baseURL = details[EXCHANGEBASEURL] { + Text("from \(baseURL.trimURL())") + .font(.title2) + .padding(.bottom) + } + } + } + } + struct DeleteButton: View { + var common : TransactionCommon + var deleteAction: (_ transactionId: String) async throws -> Void + + @State var disabled: Bool = false + @State var deleted: Bool = false + var body: some View { + Button(role: .destructive, action: { + Task { // delete from wallet-core + disabled = true // don't try this more than once + do { + try await deleteAction(common.transactionId) +// symLog.log("deleted \(common.transactionId)") + deleted = true + } catch { // TODO: error +// symLog.log(error.localizedDescription) + } + } + }, label: { + HStack { + if deleted { + Text("Deleted from list") + } else { + Text("Delete from list" + " ") + Image(systemName: "trash") + } + } + .font(.title) + .frame(maxWidth: .infinity) + }) + .buttonStyle(.bordered) + .controlSize(.large) + .padding(.horizontal) + .disabled(disabled) + } + } + struct AbortButton: View { + var common : TransactionCommon + var abortAction: (_ transactionId: String) async throws -> Void + + @State var disabled: Bool = false + @State var aborting: Bool = false + var body: some View { + Button(role: .cancel, action: { + Task { // delete from wallet-core + disabled = true // don't try this more than once + do { + try await abortAction(common.transactionId) +// symLog.log("aborted \(common.transactionId)") + aborting = true + } catch { // TODO: error +// symLog.log(error.localizedDescription) + } + } + }, label: { + HStack { + if aborting { + Text("Abort pending...") + } else { + Text("Abort" + " ") + Image(systemName: "x.circle") + } + } + .font(.title) + .frame(maxWidth: .infinity) + }) + .buttonStyle(.bordered) + .controlSize(.large) + .padding(.horizontal) + .disabled(disabled) + } + } + struct DoneButton: View { + var doneAction: () -> Void + + var body: some View { + Button("Done") { + doneAction() + } + .buttonStyle(TalerButtonStyle(type: .prominent)) + .padding(.horizontal) + } + } +} +// MARK: - +#if DEBUG +struct TransactionDetail_Previews: PreviewProvider { + static func deleteTransactionDummy(transactionId: String) async throws {} + static func doneActionDummy() {} + static var withdrawal = Transaction(incoming: true, + pending: true, + id: "some withdrawal ID", + time: Timestamp(from: 1_666_000_000_000)) + static var payment = Transaction(incoming: false, + pending: false, + id: "some payment ID", + time: Timestamp(from: 1_666_666_000_000)) + static var previews: some View { + Group { + TransactionDetailView(transaction: withdrawal, doneAction: doneActionDummy) + TransactionDetailView(transaction: payment, deleteAction: deleteTransactionDummy) + } + } +} +#endif diff --git a/TalerWallet1/Views/Transactions/TransactionRow.swift b/TalerWallet1/Views/Transactions/TransactionRow.swift deleted file mode 100644 index 899aaf4..0000000 --- a/TalerWallet1/Views/Transactions/TransactionRow.swift +++ /dev/null @@ -1,92 +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 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 common = transaction.common() - let details = transaction.detailsToShow() - let keys = details.keys - let amount = common.amountEffective - let withdraw: Bool = common.type == WITHDRAWAL - let payment: Bool = common.type == PAYMENT - let refund: Bool = common.type == REFUND - let incoming = withdraw || refund -// let counterparty = transaction.exchangeBaseUrl - let dateString = TalerDater.dateString(from: common.timestamp, relative: true) - - HStack { - Image(systemName: incoming ? "text.badge.plus" : "text.badge.minus") - .foregroundColor(incoming ? Color("Incoming") : Color("Outgoing")) - .padding(.trailing) - .font(.largeTitle) - - if keys.contains(EXCHANGEBASEURL) { - if let baseURL = details[EXCHANGEBASEURL] { - 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 withdrawal = Transaction(incoming: true, - id: "some withdrawal ID", - time: Timestamp(from: 1_666_000_000_000)) - static var payment = Transaction(incoming: false, - id: "some payment ID", - time: Timestamp(from: 1_666_666_000_000)) - static var previews: some View { - VStack { - TransactionRow(transaction: withdrawal) - TransactionRow(transaction: payment) - } - } -} -#endif diff --git a/TalerWallet1/Views/Transactions/TransactionRowView.swift b/TalerWallet1/Views/Transactions/TransactionRowView.swift new file mode 100644 index 0000000..98349c8 --- /dev/null +++ b/TalerWallet1/Views/Transactions/TransactionRowView.swift @@ -0,0 +1,137 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +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 TransactionRowView: View { + var transaction : Transaction + + var body: some View { + let common = transaction.common + let amount = common.amountEffective + let pending = transaction.isPending + let done = transaction.isDone + let details = transaction.detailsToShow() + let keys = details.keys + + let dateString = TalerDater.dateString(from: common.timestamp, relative: true) + let incoming = common.incoming() + let foreColor = pending ? WalletColors().pendingColor(incoming) + : done ? WalletColors().transactionColor(incoming) + : WalletColors().uncompletedColor + + HStack { + Image(systemName: incoming ? "text.badge.plus" : "text.badge.minus") + .foregroundColor(foreColor) + .padding(.trailing) + .font(.largeTitle) + .accessibility(hidden: true) + + if transaction.isWithdrawal { + TransactionRowCenter(centerTop: String(localized: "Withdrawal"), centerBottom: dateString) + } else if transaction.isPayment { + TransactionRowCenter(centerTop: String(localized: "Payment"), centerBottom: dateString) + } else if transaction.isP2pOutgoing { + TransactionRowCenter(centerTop: String(localized: "P2P Send"), centerBottom: dateString) + } else if transaction.isP2pIncoming { + TransactionRowCenter(centerTop: String(localized: "P2P Receive"), centerBottom: dateString) + } else if transaction.isRefund { + TransactionRowCenter(centerTop: String(localized: "Refund"), centerBottom: dateString) + } else if transaction.isRefresh { + TransactionRowCenter(centerTop: String(localized: "Refresh"), centerBottom: dateString) + } else if keys.contains(EXCHANGEBASEURL) { + if let baseURL = details[EXCHANGEBASEURL] { + TransactionRowCenter(centerTop: baseURL.trimURL(), centerBottom: dateString) + } + } + Spacer() + VStack(alignment: .trailing) { + let sign = incoming ? "+" : "-" + Text(sign + "\(amount.valueStr)") + .font(.title) + .foregroundColor(foreColor) + } + } + .accessibilityElement(children: .combine) + .padding(.top) + } +} +// MARK: - +#if DEBUG +struct TransactionRow_Previews: PreviewProvider { + static var withdrawal = Transaction(incoming: true, + pending: false, + id: "some withdrawal ID", + time: Timestamp(from: 1_666_000_000_000)) + static var payment = Transaction(incoming: false, + pending: false, + id: "some payment ID", + time: Timestamp(from: 1_666_666_000_000)) + static var previews: some View { + List { + TransactionRowView(transaction: withdrawal) + TransactionRowView(transaction: payment) + } + } +} +// MARK: - +extension Transaction { // for PreViews + init(incoming: Bool, pending: Bool, id: String, time: Timestamp) { + let currency = LONGCURRENCY + let raw = currency + ":5" + let effective = currency + (incoming ? ":4.8" + : ":5.2") + let refRaw = currency + ":3" + let refEff = currency + ":2.8" + let common = TransactionCommon(type: incoming ? .withdrawal : .payment, + txState: TransactionState(major: pending ? TransactionMajorState.pending + : TransactionMajorState.done), + amountEffective: try! Amount(fromString: effective), + amountRaw: try! Amount(fromString: raw), + transactionId: id, + timestamp: time, + txActions: [.abort]) + if incoming { + // if pending then manual else bank-integrated + let payto = "payto://iban/SANDBOXX/DE159593?receiver-name=Exchange+Company&amount=KUDOS%3A9.99&message=Taler+Withdrawal+J41FQPJGAP1BED1SFSXHC989EN8HRDYAHK688MQ228H6SKBMV0AG" + let withdrawalDetails = WithdrawalDetails(type: pending ? WithdrawalDetails.WithdrawalType.manual + : WithdrawalDetails.WithdrawalType.bankIntegrated, + reservePub: "PuBlIc_KeY_oF_tHe_ReSeRvE", + reserveIsReady: false, + exchangePaytoUris: pending ? [payto] : nil) + let wDetails = WithdrawalTransactionDetails(exchangeBaseUrl: DEMOEXCHANGE, + withdrawalDetails: withdrawalDetails) + self = .withdrawal(WithdrawalTransaction(common: common, details: wDetails)) + } else { + let merchant = Merchant(name: "some random shop") + let info = OrderShortInfo(orderId: "some order ID", + merchant: merchant, + summary: "some product summary", + products: []) + let pDetails = PaymentTransactionDetails(proposalId: "some proposal ID", + totalRefundRaw: try! Amount(fromString: refRaw), + totalRefundEffective: try! Amount(fromString: refEff), + info: info) + self = .payment(PaymentTransaction(common: common, details: pDetails)) + } + } +} +#endif diff --git a/TalerWallet1/Views/Transactions/TransactionsEmptyView.swift b/TalerWallet1/Views/Transactions/TransactionsEmptyView.swift new file mode 100644 index 0000000..1721663 --- /dev/null +++ b/TalerWallet1/Views/Transactions/TransactionsEmptyView.swift @@ -0,0 +1,37 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +import SwiftUI +import SymLog + +/// This view shows hints if a wallet is empty +/// It is the very first thing the user sees after installing the app + +struct TransactionsEmptyView: View { + private let symLog = SymLogV() + @AppStorage("listStyle") var myListStyle = MyListStyle.automatic + + let currency: String + + var body: some View { + List { + Section { + Text("There are no transactions for \(currency).") + } + } + .padding(.vertical) + .font(.title2) + .listStyle(myListStyle.style) + .anyView + .onAppear() { + DebugViewC.shared.setViewID(VIEW_EMPTY) // 10 + } + } +} + +struct TransactionsEmptyView_Previews: PreviewProvider { + static var previews: some View { + TransactionsEmptyView(currency: LONGCURRENCY) + } +} diff --git a/TalerWallet1/Views/Transactions/TransactionsListView.swift b/TalerWallet1/Views/Transactions/TransactionsListView.swift index 284a6c3..e2e5ad1 100644 --- a/TalerWallet1/Views/Transactions/TransactionsListView.swift +++ b/TalerWallet1/Views/Transactions/TransactionsListView.swift @@ -1,105 +1,112 @@ /* - * 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/> + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md */ import SwiftUI import SymLog struct TransactionsListView: View { private let symLog = SymLogV() - let navTitle = "Transactions" + @AppStorage("listStyle") var myListStyle = MyListStyle.automatic + let navTitle: String + + let currency: String + let transactions: [Transaction] + var reloadAction: () async -> () + let deleteAction: (_ transactionId: String) async throws -> () + let abortAction: (_ transactionId: String) async throws -> () - @ObservedObject var viewModel: TransactionsModel - var body: some View { - let reloadAction = viewModel.fetchTransactions - VStack { - if viewModel.transactions == nil { - symLog { LoadingView(backButtonHidden: false) } - } else { - let count = viewModel.transactions!.count - let title: String = "\(count) \(navTitle)" - symLog { Content(symLog: symLog, viewModel: viewModel, reloadAction: reloadAction) - .navigationTitle(title) - } +#if DEBUG + let _ = Self._printChanges() + let _ = symLog.vlog() // just to get the # to compare it with .onAppear & onDisappear +#endif + let count = transactions.count +// let title = AttributedString(localized: "^[\(count) Ticket](inflect: true)") + let title: String = "\(count) \(navTitle)" + Content(symLog: symLog, currency: currency, transactions: transactions, myListStyle: $myListStyle, + reloadAction: reloadAction, deleteAction: deleteAction, abortAction: abortAction) + .navigationTitle(title) + .onAppear { + DebugViewC.shared.setViewID(VIEW_TRANSACTIONLIST) } - }.task { - symLog.log(".task") - do { - try await reloadAction() - } catch { - // TODO: show error - symLog.log(error.localizedDescription) + .task { + symLog.log(".task ") + await reloadAction() } - } } } // MARK: - extension TransactionsListView { struct Content: View { let symLog: SymLogV? - @ObservedObject var viewModel: TransactionsModel - - var reloadAction: () async throws -> () + let currency: String + let transactions: [Transaction] + @Binding var myListStyle: MyListStyle + let reloadAction: () async -> () + let deleteAction: (_ transactionId: String) async throws -> () + let abortAction: (_ transactionId: String) async throws -> () @State private var upAction: () -> Void = {} @State private var downAction: () -> Void = {} +// func removeItems(at offsets: IndexSet) { +// let transactions = model.transactions +// var idsToDelete: [String] = [] +// for n in offsets { // save IDs of transactions +// let common = transactions[n].common +// idsToDelete.append(common.transactionId) +// } +// // then remove items from the list model (and the view) +// model.transactions.remove(atOffsets: offsets) +// // finally tell wallet-core to delete the saved IDs +// Task { // delete this transaction from wallet-core +// for transactionId in idsToDelete { +// do { +// try await deleteAction(transactionId) +// symLog?.log("deleted \(transactionId)") +// } catch { // TODO: error +// symLog?.log(error.localizedDescription) +// } +// } +// } +// } + var body: some View { - let transactions = viewModel.transactions! +#if DEBUG + let _ = Self._printChanges() + let _ = symLog?.vlog() // just to get the # to compare it with .onAppear & onDisappear +#endif ScrollViewReader { scrollView in List { - ForEach (transactions.indices) { index in - let transaction = transactions[index] - let common = transaction.common() - NavigationLink { - TransactionDetail(transaction: transaction) - } label: { - TransactionRow(transaction: transaction) + ForEach(Array(zip(transactions.indices, transactions)), id: \.1) { index, transaction in +// let common = transaction.common + NavigationLink { LazyView { // whole row like in a tableView + // pending may not be deleted, but only aborted + TransactionDetailView(transaction: transaction, + deleteAction: deleteAction, + abortAction: abortAction) + }} label: { + TransactionRowView(transaction: transaction) } - .id(index) - .swipeActions(edge: .leading, allowsFullSwipe: true) { - Button { - symLog?.log("bookmarked \(common.transactionId)") - // TODO: Bookmark - } label: { - Label("Bookmark", systemImage: "bookmark") - }.tint(.indigo) - } - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - Button(role: .destructive) { - symLog?.log("deleted \(common.transactionId)") - // TODO: delete from Model. SwiftUI deletes this row from view already :-) - } label: { - Label("Delete", systemImage: "trash") - } - } - } - .onAppear { - upAction = { withAnimation { scrollView.scrollTo(0) }} - downAction = { withAnimation { scrollView.scrollTo(transactions.count - 1) }} - downAction() } +// .onDelete(perform: removeItems) // delete this row from the list } .refreshable { - do { - symLog?.log("refreshing") - try await reloadAction() - } catch { - // TODO: error - symLog?.log(error.localizedDescription) + await reloadAction() + } + .listStyle(myListStyle.style) + .anyView + .onAppear { + upAction = { withAnimation { scrollView.scrollTo(0) }} + downAction = { withAnimation { scrollView.scrollTo(transactions.count - 1) }} + downAction() + } + .overlay { + if transactions.isEmpty { + TransactionsEmptyView(currency: currency) + .background(WalletColors().backgroundColor.edgesIgnoringSafeArea(.all)) } } } diff --git a/TalerWallet1/Views/Transactions/TransactionsModel.swift b/TalerWallet1/Views/Transactions/TransactionsModel.swift deleted file mode 100644 index 253a557..0000000 --- a/TalerWallet1/Views/Transactions/TransactionsModel.swift +++ /dev/null @@ -1,61 +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 -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 -} - -// 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 deleted file mode 100644 index 1aaab6b..0000000 --- a/TalerWallet1/Views/URLSheet.swift +++ /dev/null @@ -1,64 +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 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 deleted file mode 100644 index 4f9578c..0000000 --- a/TalerWallet1/Views/Withdraw/WithdrawAcceptView.swift +++ /dev/null @@ -1,71 +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 -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! Amount.diff(raw, effective) // TODO: different currencies - 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 deleted file mode 100644 index 2c08b75..0000000 --- a/TalerWallet1/Views/Withdraw/WithdrawProgressView.swift +++ /dev/null @@ -1,45 +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 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 deleted file mode 100644 index b1c3cf6..0000000 --- a/TalerWallet1/Views/Withdraw/WithdrawTOSView.swift +++ /dev/null @@ -1,96 +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 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/WithdrawURIView.swift b/TalerWallet1/Views/Withdraw/WithdrawURIView.swift deleted file mode 100644 index e928e44..0000000 --- a/TalerWallet1/Views/Withdraw/WithdrawURIView.swift +++ /dev/null @@ -1,103 +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 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: \(String(describing: 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 -// } - } - } - } -} diff --git a/TalerWallet1/Views/WithdrawBankIntegrated/WithdrawAcceptView.swift b/TalerWallet1/Views/WithdrawBankIntegrated/WithdrawAcceptView.swift new file mode 100644 index 0000000..d0a1269 --- /dev/null +++ b/TalerWallet1/Views/WithdrawBankIntegrated/WithdrawAcceptView.swift @@ -0,0 +1,71 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +import SwiftUI +import taler_swift +import SymLog + +struct WithdrawAcceptView: View { + private let symLog = SymLogV() + let url: URL + @ObservedObject var model: WithdrawModel + + let navTitle = String(localized: "Accept Withdrawal") + let detailsForAmount: ManualWithdrawalDetails + let baseURL: String + + func acceptAction() -> () { + Task { + do { + let confirmTransferUrl = try await model.sendAcceptIntWithdrawalM(baseURL, withdrawURL: url.absoluteString) + symLog.log(confirmTransferUrl as Any) + // TODO: Show Hints that User should Confirm on bank website + // update balances to show pending withdrawal + await BalancesModel.model(currency: "*").fetchBalancesM() + } catch { // TODO: error + symLog.log(error.localizedDescription) + } + } + } + + var body: some View { + Group { + let currState = model.withdrawState + switch currState { + case .receivedAmountDetails, .receivedTOS, .receivedTOSAck: + let raw = detailsForAmount.amountRaw + let effective = detailsForAmount.amountEffective + let fee = try! Amount.diff(raw, effective) // TODO: different currencies + let outColor = WalletColors().transactionColor(false) + let inColor = WalletColors().transactionColor(true) + + ThreeAmountsView(topTitle: String(localized: "Chosen amount to withdraw:"), + topAmount: raw, fee: fee, + bottomTitle: String(localized: "Coins to be withdrawn:"), + bottomAmount: effective, + large: false, pending: false, incoming: true, + baseURL: baseURL) + .safeAreaInset(edge: .bottom) { + ProminentButton(title: String(localized: "Accept"), action: acceptAction) + .padding(.horizontal) + } + case .waitingForWithdrAck, .receivedWithdrAck: + // TODO: SHEET_WITHDRAW_CONFIRM + Text("waiting for bank confirmation") + .navigationTitle("Confirm with Bank") + .onAppear() { + DebugViewC.shared.setSheetID(SHEET_WITHDRAW_CONFIRM) + } + + default: + let _ = symLog.vlog(currState as Any) + ErrorView(errortext: "unknown state") // TODO: Error + } + } + .navigationTitle(navTitle) + .onAppear() { + DebugViewC.shared.setSheetID(SHEET_WITHDRAW_ACCEPT) + } + } +} diff --git a/TalerWallet1/Views/WithdrawBankIntegrated/WithdrawProgressView.swift b/TalerWallet1/Views/WithdrawBankIntegrated/WithdrawProgressView.swift new file mode 100644 index 0000000..c872021 --- /dev/null +++ b/TalerWallet1/Views/WithdrawBankIntegrated/WithdrawProgressView.swift @@ -0,0 +1,27 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +import SwiftUI + +struct WithdrawProgressView: View { + let message: String + + var body: some View { + VStack { + Spacer() + ProgressView() + Spacer() + Text(message) + .font(.title) + Spacer() + Spacer() + } + } +} + +struct WithdrawProgressView_Previews: PreviewProvider { + static var previews: some View { + WithdrawProgressView(message: "message") + } +} diff --git a/TalerWallet1/Views/WithdrawBankIntegrated/WithdrawTOSView.swift b/TalerWallet1/Views/WithdrawBankIntegrated/WithdrawTOSView.swift new file mode 100644 index 0000000..48a8c0e --- /dev/null +++ b/TalerWallet1/Views/WithdrawBankIntegrated/WithdrawTOSView.swift @@ -0,0 +1,90 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +import SwiftUI +import SymLog + +struct WithdrawTOSView: View { + private let symLog = SymLogV() + @AppStorage("listStyle") var myListStyle = MyListStyle.automatic + + let navTitle = String(localized: "Terms of Service") + + var exchangeBaseUrl: String + @ObservedObject var model: WithdrawModel + + @State var exchangeTOS: ExchangeTermsOfService? + let viewID: Int // either VIEW_WITHDRAW_TOS or SHEET_WITHDRAW_TOS + + let acceptAction: (() -> Void)? + @Environment(\.presentationMode) var presentationMode + + var body: some View { + VStack { + switch model.withdrawState { + case .receivedAmountDetails, .waitingForTOS: + WithdrawProgressView(message: exchangeBaseUrl.trimURL()) + .navigationTitle("Loading " + navTitle) + case .receivedTOS, .waitingForTOSAck, .receivedTOSAck: + Content(symLog: symLog, exchangeTOS: exchangeTOS, myListStyle: $myListStyle) { + Task { + do { + _ = try await model.setExchangeTOSAcceptedM(exchangeBaseUrl, etag: exchangeTOS!.currentEtag) + if acceptAction != nil { + acceptAction!() + } else { + self.presentationMode.wrappedValue.dismiss() + } + } catch { // TODO: Show Error + symLog.log(error.localizedDescription) + } + } + } + .navigationBarTitleDisplayMode(.large) // .inline + .navigationTitle(navTitle) + default: + ErrorView(errortext: "unknown state") // TODO: ??? + } + }.onAppear() { + if viewID > SHEET_WITHDRAWAL { + DebugViewC.shared.setSheetID(SHEET_WITHDRAW_TOS) + } else { + DebugViewC.shared.setViewID(VIEW_WITHDRAW_TOS) + } + }.task { + do { + let someTOS = try await model.loadExchangeTermsOfServiceM(exchangeBaseUrl) + exchangeTOS = someTOS + } catch { // TODO: error + symLog.log(error.localizedDescription) + } + } + } +} +// MARK: - +extension WithdrawTOSView { + struct Content: View { + let symLog: SymLogV + var exchangeTOS: ExchangeTermsOfService? + @Binding var myListStyle: MyListStyle + var acceptAction: () -> () + + var body: some View { + if let tos = exchangeTOS { + let components = tos.content.components(separatedBy:"\n\n") + + List (components, id: \.self) { term in + Text(term) + }.safeAreaInset(edge: .bottom) { + ProminentButton(title: String(localized: "Accept"), action: acceptAction) + .padding(.horizontal) + } + .listStyle(myListStyle.style) + .anyView + } else { + ErrorView(errortext: String(localized: "unknown ToS")) // TODO: ??? + } + } + } +} diff --git a/TalerWallet1/Views/WithdrawBankIntegrated/WithdrawURIView.swift b/TalerWallet1/Views/WithdrawBankIntegrated/WithdrawURIView.swift new file mode 100644 index 0000000..246daaa --- /dev/null +++ b/TalerWallet1/Views/WithdrawBankIntegrated/WithdrawURIView.swift @@ -0,0 +1,76 @@ +/* + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md + */ +import SwiftUI +import SymLog + +struct WithdrawURIView: View { + private let symLog = SymLogV() + var url: URL + @ObservedObject var model: WithdrawModel + +// @State var withdrawUriInfo: WithdrawUriInfoResponse? + @State var exchangeBaseUrl: String? + @State var manualWithdrawalDetails: ManualWithdrawalDetails? + @State var didAcceptTOS: Bool = false + + var body: some View { + let badURL = "Error in URL: \(url)" + VStack { + let currState = model.withdrawState + if currState == nil { + LoadingView(backButtonHidden: false) + } else { + let _ = symLog.vlog(currState as Any) + switch currState { + case .waitingForUriDetails, .receivedUriDetails: + WithdrawProgressView(message: url.host ?? badURL) + .navigationTitle("Contacting Exchange") + case .waitingForAmountDetails: + WithdrawProgressView(message: exchangeBaseUrl?.trimURL() ?? badURL) + .navigationTitle("Found Exchange") + default: + // .receivedAmountDetails, .waitingForTOS, .receivedTOS, .waitingForTOSAck, .receivedTOSAck + // waitingForWithdrAck, receivedWithdrAck + if !didAcceptTOS { + // user must accept ToS first + WithdrawTOSView(exchangeBaseUrl: exchangeBaseUrl!, + model: model, + viewID: SHEET_WITHDRAW_TOS) { + didAcceptTOS = true + } + } else { + // show Amount details and let user accept + WithdrawAcceptView(url: url, model: model, + detailsForAmount: manualWithdrawalDetails!, + baseURL: exchangeBaseUrl!) + } + } + } + }.onAppear() { + DebugViewC.shared.setSheetID(SHEET_WITHDRAWAL) + }.task { + do { // TODO: cancelled + symLog.log(".task") + let withdrawUriInfo = try await model.loadWithdrawalDetailsForUriM(url.absoluteString) + let amount = withdrawUriInfo.amount + if let baseURL = withdrawUriInfo.defaultExchangeBaseUrl { + exchangeBaseUrl = baseURL + } else { + exchangeBaseUrl = withdrawUriInfo.possibleExchanges.first?.exchangeBaseUrl + } + symLog.log("amount: \(amount), baseURL: \(String(describing: exchangeBaseUrl))") + // TODO: let user choose exchange from list + manualWithdrawalDetails = try await model.loadWithdrawalDetailsForAmountM(exchangeBaseUrl!, amount: amount) + + symLog.log("raw: \(manualWithdrawalDetails!.amountRaw), effective: \(manualWithdrawalDetails!.amountEffective)") + if manualWithdrawalDetails!.tosAccepted { + didAcceptTOS = true + } + } catch { // TODO: error + symLog.log(error.localizedDescription) + } + } + } +} diff --git a/taler-swift/Sources/taler-swift/Amount.swift b/taler-swift/Sources/taler-swift/Amount.swift index d97612e..cfd01e0 100644 --- a/taler-swift/Sources/taler-swift/Amount.swift +++ b/taler-swift/Sources/taler-swift/Amount.swift @@ -1,17 +1,6 @@ /* - * 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/> + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md */ import Foundation @@ -169,7 +158,12 @@ public class Amount: Codable, Hashable, CustomStringConvertible { self.integer = integer self.fraction = fraction } - + public init(currency: String, value: UInt64) { + self.currency = currency + self.integer = value / 100 // TODO: fractional digits can be 0, 2 or 3 + self.fraction = UInt32(value - (self.integer * 100)) + } + /// Initializes an amount from a decoder. /// - Parameters: /// - from: The decoder to extract the amount from. @@ -282,8 +276,10 @@ public class Amount: Codable, Hashable, CustomStringConvertible { } var remainder = result.integer % UInt64(divisor) result.integer = result.integer / UInt64(divisor) - remainder = (remainder * UInt64(Amount.fractionalBase)) + UInt64(result.fraction) - result.fraction = UInt32(remainder) / divisor + + let fractionalBase = UInt64(Amount.fractionalBase) + remainder = (remainder * fractionalBase) + UInt64(result.fraction) + result.fraction = UInt32(remainder / UInt64(divisor)) try result.normalize() return result } @@ -392,4 +388,19 @@ public class Amount: Codable, Hashable, CustomStringConvertible { public static func zero(currency: String) -> Amount { return Amount(currency: currency, integer: 0, fraction: 0) } + + public static func amountFromCents(_ currency: String, _ cents: UInt64) -> Amount { + let amount100 = Amount(currency: currency, integer: cents, fraction: 0) + do { + let amount = try amount100 / 100 + return amount + } catch { // shouldn't happen, but if it does then truncate + return Amount(currency: currency, integer: cents / 100, fraction: 0) + } + } +} +// MARK: - +extension Amount: Identifiable { + // needed to be passed as value for .sheet + public var id: Amount {self} } diff --git a/taler-swift/Sources/taler-swift/Time.swift b/taler-swift/Sources/taler-swift/Time.swift index 1d76053..dffb53d 100644 --- a/taler-swift/Sources/taler-swift/Time.swift +++ b/taler-swift/Sources/taler-swift/Time.swift @@ -1,17 +1,6 @@ /* - * 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/> + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md */ import Foundation @@ -45,7 +34,7 @@ public enum Timestamp: Codable, Hashable { } else { self = Timestamp.milliseconds(try container.decode(UInt64.self, forKey: .t_ms)) } - } catch { + } catch { // rethrows or never let stringValue = try container.decode(String.self, forKey: .t_s) if stringValue == "never" { self = Timestamp.never @@ -79,6 +68,17 @@ extension Timestamp { return Timestamp.milliseconds(Date().millisecondsSince1970) } + public static func tomorrow() -> Timestamp { + return Timestamp.inSomeDays(1) + } + + public static func inSomeDays(_ days: UInt) -> Timestamp { + let now = Date().millisecondsSince1970 + let seconds: UInt64 = 60 * 60 * 24 + return Timestamp.milliseconds(now + (UInt64(days) * seconds * 1000)) + } + + /// convenience initializer from UInt64 (milliseconds from January 1, 1970) public init(from: UInt64) { self = Timestamp.milliseconds(from) diff --git a/taler-swift/Tests/taler-swiftTests/AmountTests.swift b/taler-swift/Tests/taler-swiftTests/AmountTests.swift index 429e9ef..a0b1f27 100644 --- a/taler-swift/Tests/taler-swiftTests/AmountTests.swift +++ b/taler-swift/Tests/taler-swiftTests/AmountTests.swift @@ -1,17 +1,6 @@ /* - * 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/> + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md */ import XCTest @testable import taler_swift diff --git a/taler-swift/Tests/taler-swiftTests/TimeTests.swift b/taler-swift/Tests/taler-swiftTests/TimeTests.swift index 4bfd5c6..a26128a 100644 --- a/taler-swift/Tests/taler-swiftTests/TimeTests.swift +++ b/taler-swift/Tests/taler-swiftTests/TimeTests.swift @@ -1,17 +1,6 @@ /* - * 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/> + * This file is part of GNU Taler, ©2022-23 Taler Systems S.A. + * See LICENSE.md */ import XCTest @testable import taler_swift |