From 204675de15c6619c55fa85a789c925de7e3a9252 Mon Sep 17 00:00:00 2001 From: Robbie Hanson <304604+robbiehanson@users.noreply.github.com> Date: Thu, 18 Jul 2024 11:46:32 -0500 Subject: [PATCH 01/13] (ios) Contacts UI v1.0 --- .../phoenix-ios.xcodeproj/project.pbxproj | 38 +- phoenix-ios/phoenix-ios/AppDelegate.swift | 2 +- .../user_round.imageset/Contents.json | 21 - .../user_round.imageset/user-round.svg | 1 - .../user_round_symbol.symbolset/Contents.json | 12 - .../user-round.SFSymbol.svg | 161 --- phoenix-ios/phoenix-ios/Localizable.xcstrings | 87 +- .../extensions/NSItemProvider+Async.swift | 87 ++ .../kotlin/KotlinExtensions+Payments.swift | 98 +- .../phoenix-ios/kotlin/KotlinTypes.swift | 3 +- .../phoenix-ios/officers/PhotosManager.swift | 196 ++++ phoenix-ios/phoenix-ios/prefs/Prefs.swift | 18 +- phoenix-ios/phoenix-ios/utils/Cache.swift | 9 +- .../ScrollingDismissesKeyboard.swift | 41 + .../DisplayConfigurationView.swift | 18 - .../payment options/PaymentOptionsView.swift | 40 - .../views/contacts/ContactPhoto.swift | 106 ++ .../views/contacts/ContactsList.swift | 224 +++- .../views/contacts/ContactsListSheet.swift | 242 +++++ .../views/contacts/ManageContact.swift | 954 ++++++++++++++++++ .../views/contacts/ManageContactSheet.swift | 395 -------- .../views/inspect/EditInfoView.swift | 4 +- .../views/inspect/SummaryInfoGrid.swift | 91 +- .../views/inspect/SummaryView.swift | 149 ++- .../phoenix-ios/views/layers/Popover.swift | 45 +- .../phoenix-ios/views/layers/ShortSheet.swift | 45 +- .../phoenix-ios/views/layers/SmartModal.swift | 20 +- .../phoenix-ios/views/main/HomeView.swift | 40 +- .../phoenix-ios/views/send/ManualInput.swift | 97 ++ .../views/send/PaymentDetails.swift | 18 +- .../phoenix-ios/views/send/ScanView.swift | 150 +-- .../phoenix-ios/views/send/SendView.swift | 11 +- .../phoenix-ios/views/send/ValidateView.swift | 25 +- .../views/transactions/PaymentCell.swift | 67 +- .../views/transactions/TransactionsView.swift | 25 +- .../views/widgets/CameraPicker.swift | 8 +- .../views/widgets/ImagePicker.swift | 65 +- .../views/widgets/PickerResult.swift | 201 ++++ .../NotificationService.swift | 1 + .../fr.acinq.phoenix/data/WalletPayment.kt | 11 +- .../db/payments/MetadataQueries.kt | 2 +- .../managers/ContactsManager.kt | 56 + .../managers/PaymentsManager.kt | 12 +- .../kotlin/fr/acinq/phoenix/db/CloudKitDb.kt | 9 +- 44 files changed, 2954 insertions(+), 951 deletions(-) delete mode 100644 phoenix-ios/phoenix-ios/Assets.xcassets/user_round.imageset/Contents.json delete mode 100644 phoenix-ios/phoenix-ios/Assets.xcassets/user_round.imageset/user-round.svg delete mode 100644 phoenix-ios/phoenix-ios/Assets.xcassets/user_round_symbol.symbolset/Contents.json delete mode 100644 phoenix-ios/phoenix-ios/Assets.xcassets/user_round_symbol.symbolset/user-round.SFSymbol.svg create mode 100644 phoenix-ios/phoenix-ios/extensions/NSItemProvider+Async.swift create mode 100644 phoenix-ios/phoenix-ios/officers/PhotosManager.swift create mode 100644 phoenix-ios/phoenix-ios/views/compatibility/ScrollingDismissesKeyboard.swift create mode 100644 phoenix-ios/phoenix-ios/views/contacts/ContactPhoto.swift create mode 100644 phoenix-ios/phoenix-ios/views/contacts/ContactsListSheet.swift create mode 100644 phoenix-ios/phoenix-ios/views/contacts/ManageContact.swift delete mode 100644 phoenix-ios/phoenix-ios/views/contacts/ManageContactSheet.swift create mode 100644 phoenix-ios/phoenix-ios/views/send/ManualInput.swift create mode 100644 phoenix-ios/phoenix-ios/views/widgets/PickerResult.swift diff --git a/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj b/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj index c39b5ae89..32166b321 100644 --- a/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj +++ b/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj @@ -92,7 +92,7 @@ DC2F431A27B699800006FCC4 /* ModifyInvoiceSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC2F431927B699800006FCC4 /* ModifyInvoiceSheet.swift */; }; DC32FB3529A3D3FE009912AC /* XpcManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC32FB3429A3D3FE009912AC /* XpcManager.swift */; }; DC33369826BAF721000E3F49 /* ShortSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC33369726BAF721000E3F49 /* ShortSheet.swift */; }; - DC3345D02C2B4C1200EDD2D4 /* ManageContactSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3345CF2C2B4C1200EDD2D4 /* ManageContactSheet.swift */; }; + DC3345D02C2B4C1200EDD2D4 /* ManageContact.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3345CF2C2B4C1200EDD2D4 /* ManageContact.swift */; }; DC3345D22C2C761800EDD2D4 /* CameraPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC3345D12C2C761800EDD2D4 /* CameraPicker.swift */; }; DC33C5632A7C15D40053D785 /* MainView_BigPrimary.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC33C5622A7C15D40053D785 /* MainView_BigPrimary.swift */; }; DC34399F276CEFB600CAA73A /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7555FF84242A565B00829871 /* Assets.xcassets */; }; @@ -186,6 +186,13 @@ DC6D68272AC1DD5C0099929F /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DC6D68262AC1DD5C0099929F /* Localizable.xcstrings */; }; DC6D68292AC1DD5C0099929F /* InfoPlist.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = DC6D68282AC1DD5C0099929F /* InfoPlist.xcstrings */; }; DC6F04232C35EB9900627B4F /* SummaryInfoGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6F04222C35EB9900627B4F /* SummaryInfoGrid.swift */; }; + DC6F04252C38807300627B4F /* PhotosManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6F04242C38807300627B4F /* PhotosManager.swift */; }; + DC6F04272C3895E300627B4F /* ContactPhoto.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6F04262C3895E300627B4F /* ContactPhoto.swift */; }; + DC6F042B2C3DA7AD00627B4F /* ContactsListSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6F042A2C3DA7AD00627B4F /* ContactsListSheet.swift */; }; + DC6F042D2C3DC4CD00627B4F /* ManualInput.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6F042C2C3DC4CD00627B4F /* ManualInput.swift */; }; + DC6F19BF2C46FB0F004EC469 /* NSItemProvider+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6F19BE2C46FB0F004EC469 /* NSItemProvider+Async.swift */; }; + DC6F19C12C470F70004EC469 /* PickerResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6F19C02C470F70004EC469 /* PickerResult.swift */; }; + DC6F19C32C49B7E2004EC469 /* ScrollingDismissesKeyboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6F19C22C49B7E2004EC469 /* ScrollingDismissesKeyboard.swift */; }; DC70A99C2BBB6093002DBFF8 /* InboundFeeWarning.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC70A99B2BBB6093002DBFF8 /* InboundFeeWarning.swift */; }; DC71E7302723240E0063613D /* KotlinObservables.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC71E72F2723240E0063613D /* KotlinObservables.swift */; }; DC71E7332728645B0063613D /* CurrencyConverterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC71E7322728645B0063613D /* CurrencyConverterView.swift */; }; @@ -497,7 +504,7 @@ DC2F431927B699800006FCC4 /* ModifyInvoiceSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModifyInvoiceSheet.swift; sourceTree = ""; }; DC32FB3429A3D3FE009912AC /* XpcManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = XpcManager.swift; sourceTree = ""; }; DC33369726BAF721000E3F49 /* ShortSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortSheet.swift; sourceTree = ""; }; - DC3345CF2C2B4C1200EDD2D4 /* ManageContactSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManageContactSheet.swift; sourceTree = ""; }; + DC3345CF2C2B4C1200EDD2D4 /* ManageContact.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManageContact.swift; sourceTree = ""; }; DC3345D12C2C761800EDD2D4 /* CameraPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CameraPicker.swift; sourceTree = ""; }; DC33C5622A7C15D40053D785 /* MainView_BigPrimary.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView_BigPrimary.swift; sourceTree = ""; }; DC355E1C2A4398A8008E8A8E /* NoticeBox.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoticeBox.swift; sourceTree = ""; }; @@ -576,6 +583,13 @@ DC6D68262AC1DD5C0099929F /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; DC6D68282AC1DD5C0099929F /* InfoPlist.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = InfoPlist.xcstrings; sourceTree = ""; }; DC6F04222C35EB9900627B4F /* SummaryInfoGrid.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SummaryInfoGrid.swift; sourceTree = ""; }; + DC6F04242C38807300627B4F /* PhotosManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PhotosManager.swift; sourceTree = ""; }; + DC6F04262C3895E300627B4F /* ContactPhoto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactPhoto.swift; sourceTree = ""; }; + DC6F042A2C3DA7AD00627B4F /* ContactsListSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsListSheet.swift; sourceTree = ""; }; + DC6F042C2C3DC4CD00627B4F /* ManualInput.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualInput.swift; sourceTree = ""; }; + DC6F19BE2C46FB0F004EC469 /* NSItemProvider+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSItemProvider+Async.swift"; sourceTree = ""; }; + DC6F19C02C470F70004EC469 /* PickerResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickerResult.swift; sourceTree = ""; }; + DC6F19C22C49B7E2004EC469 /* ScrollingDismissesKeyboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollingDismissesKeyboard.swift; sourceTree = ""; }; DC70A99B2BBB6093002DBFF8 /* InboundFeeWarning.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InboundFeeWarning.swift; sourceTree = ""; }; DC71E72F2723240E0063613D /* KotlinObservables.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KotlinObservables.swift; sourceTree = ""; }; DC71E7322728645B0063613D /* CurrencyConverterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrencyConverterView.swift; sourceTree = ""; }; @@ -775,6 +789,7 @@ DC6D26E229E76557006A7814 /* AnimatedClock.swift */, DCB30E532A0AABAF00E7D7A2 /* InfoPopoverWindow.swift */, DC3345D12C2C761800EDD2D4 /* CameraPicker.swift */, + DC6F19C02C470F70004EC469 /* PickerResult.swift */, ); path = widgets; sourceTree = ""; @@ -815,9 +830,9 @@ DCA3B41A2A5220A200E6B231 /* notifications */, DC2F431227B696A40006FCC4 /* receive */, DC118BF627B44E4C0080BBAC /* send */, - DC3345CE2C2B4BED00EDD2D4 /* contacts */, DCCD045B27EE0101007D57A5 /* inspect */, DCD1208528663F2D00EB39C5 /* transactions */, + DC3345CE2C2B4BED00EDD2D4 /* contacts */, DC71E7312728643C0063613D /* tools */, DCFA8757260E6DFF00AE8953 /* onboarding */, C8D7A7335040D755924F8FFC /* configuration */, @@ -966,6 +981,7 @@ DCA02B9F2BD1A5FC0080520F /* ChannelSizeImpactWarning.swift */, DCB62F482A5E09F900912A71 /* SpliceOutProblem.swift */, DC1718A62C20BF8A000CCAF5 /* PayOfferProblem.swift */, + DC6F042C2C3DC4CD00627B4F /* ManualInput.swift */, ); path = send; sourceTree = ""; @@ -1013,8 +1029,10 @@ DC3345CE2C2B4BED00EDD2D4 /* contacts */ = { isa = PBXGroup; children = ( - DC3345CF2C2B4C1200EDD2D4 /* ManageContactSheet.swift */, + DC3345CF2C2B4C1200EDD2D4 /* ManageContact.swift */, DC5567442C2F1A6900008E11 /* ContactsList.swift */, + DC6F042A2C3DA7AD00627B4F /* ContactsListSheet.swift */, + DC6F04262C3895E300627B4F /* ContactPhoto.swift */, ); path = contacts; sourceTree = ""; @@ -1115,6 +1133,7 @@ DC63BDF329AE44380067A361 /* NotificationsManager.swift */, DCD7E0AE28EC3C0D009C30E5 /* WatchTower.swift */, DC175C1B28F008AE0086B9A6 /* WalletReset.swift */, + DC6F04242C38807300627B4F /* PhotosManager.swift */, ); path = officers; sourceTree = ""; @@ -1240,6 +1259,7 @@ DCAC9FC5296879770098D769 /* NavigationStackDestination.swift */, DC4CF3C52BE59E4B003A957F /* TextTracking.swift */, DC2ABAD62BED081400C11C9C /* VibrationFeedback.swift */, + DC6F19C22C49B7E2004EC469 /* ScrollingDismissesKeyboard.swift */, ); path = compatibility; sourceTree = ""; @@ -1296,6 +1316,7 @@ DC2CE3AE29AFEB0500BA0B00 /* Bundle+Icon.swift */, DCFB8DF62A94066100947698 /* Task+Sleep.swift */, DCFB8DF82A94112A00947698 /* Dictionary+MapKeys.swift */, + DC6F19BE2C46FB0F004EC469 /* NSItemProvider+Async.swift */, ); path = extensions; sourceTree = ""; @@ -1780,11 +1801,13 @@ DC82EED629789853007A5853 /* TxHistoryExporter.swift in Sources */, DC98D3982AF2AE41005BD177 /* ReceiveView.swift in Sources */, DC9E7EC32A12955300A5F1D0 /* LiquidityHTML.swift in Sources */, + DC6F19BF2C46FB0F004EC469 /* NSItemProvider+Async.swift in Sources */, DCCFE6C22B7140FA002FFF11 /* LoggerFactory+Foreground.swift in Sources */, 7555FF7F242A565900829871 /* AppDelegate.swift in Sources */, DC74174B270F332700F7E3E3 /* KotlinTypes.swift in Sources */, DC142135261E72320075857A /* AboutHTML.swift in Sources */, DC33C5632A7C15D40053D785 /* MainView_BigPrimary.swift in Sources */, + DC6F19C12C470F70004EC469 /* PickerResult.swift in Sources */, DCEE8998288605FD00FE42DD /* PaymentCell.swift in Sources */, DCDD9ED428637EBB001800A3 /* ToolsButton.swift in Sources */, DCDD9ED0286377B7001800A3 /* MainView_Big.swift in Sources */, @@ -1829,6 +1852,7 @@ DC118C0627B4557D0080BBAC /* ValidateView.swift in Sources */, DC118C0827B457520080BBAC /* FetchActivityNotice.swift in Sources */, DCD7E0AC28EC32CB009C30E5 /* BusinessManager.swift in Sources */, + DC6F19C32C49B7E2004EC469 /* ScrollingDismissesKeyboard.swift in Sources */, DC4864DA29D4E52C00ACD539 /* BgRefreshDisabledPopover.swift in Sources */, DCE7233027B167240017CF56 /* SyncSeedManager_Actor.swift in Sources */, DCBA371B2758076F00610EC8 /* SyncSeedManager.swift in Sources */, @@ -1847,6 +1871,7 @@ DC98D3962AF170AC005BD177 /* PaymentWarningPopover.swift in Sources */, DC48D2C42593DE30008D138C /* TextFieldCurrencyStyler.swift in Sources */, DC18C41D256FF91100A2D083 /* Utils.swift in Sources */, + DC6F042D2C3DC4CD00627B4F /* ManualInput.swift in Sources */, 53BEFF4EDB40975C2123C63D /* ui.swift in Sources */, DCACF6FD2566D0BA0009B01E /* SecurityFile.swift in Sources */, DC20BDF02C233F8200338AA2 /* PaymentDetails.swift in Sources */, @@ -1885,9 +1910,10 @@ DC63BDF429AE44380067A361 /* NotificationsManager.swift in Sources */, DC118BFA27B44F840080BBAC /* TipSliderSheet.swift in Sources */, DC2F431827B698E20006FCC4 /* ShareOptionsSheet.swift in Sources */, + DC6F04252C38807300627B4F /* PhotosManager.swift in Sources */, DC72C33425A51AAC008A927A /* CurrencyPrefs.swift in Sources */, DCE6FB8C28D0B5F200054511 /* ResetWalletView.swift in Sources */, - DC3345D02C2B4C1200EDD2D4 /* ManageContactSheet.swift in Sources */, + DC3345D02C2B4C1200EDD2D4 /* ManageContact.swift in Sources */, DCE7232E27AD68CD0017CF56 /* SyncTxManager_Actor.swift in Sources */, DC16965F27FE0FAC003DE1DD /* KotlinExtensions+Currency.swift in Sources */, DC65BBF22A58A40700EBA651 /* CpfpView.swift in Sources */, @@ -1936,6 +1962,7 @@ DCB30E592A0C3F8200E7D7A2 /* LiquidityPolicyView.swift in Sources */, DCEAE5B72943CC7400320C46 /* RangeSheet.swift in Sources */, DCAC5B7027726FC80077BB98 /* DeepLink.swift in Sources */, + DC6F042B2C3DA7AD00627B4F /* ContactsListSheet.swift in Sources */, DC9473FA261270B4008D7242 /* MVI+Mock.swift in Sources */, DCDD9ECE28637474001800A3 /* Orientation.swift in Sources */, DCCFE6B02B64326F002FFF11 /* OSLogHandler.swift in Sources */, @@ -2005,6 +2032,7 @@ DCDAA7402971C29700B406A8 /* RecentPaymentsSelector.swift in Sources */, DC2ABAD72BED081400C11C9C /* VibrationFeedback.swift in Sources */, DCD5FF4326A0D34B009CC666 /* EqualSizes.swift in Sources */, + DC6F04272C3895E300627B4F /* ContactPhoto.swift in Sources */, DCB62F472A5DF19D00912A71 /* KotlinPublishers+Lightning.swift in Sources */, DC355E252A45FDD3008E8A8E /* NoticeMonitor.swift in Sources */, F4AED298257A50CD009485C1 /* LogsConfigurationView.swift in Sources */, diff --git a/phoenix-ios/phoenix-ios/AppDelegate.swift b/phoenix-ios/phoenix-ios/AppDelegate.swift index 3a663ba38..ae51b79a1 100644 --- a/phoenix-ios/phoenix-ios/AppDelegate.swift +++ b/phoenix-ios/phoenix-ios/AppDelegate.swift @@ -11,7 +11,7 @@ fileprivate var log = LoggerFactory.shared.logger(filename, .trace) fileprivate var log = LoggerFactory.shared.logger(filename, .warning) #endif -let CONTACTS_ENABLED = false +let CONTACTS_ENABLED = true @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate, MessagingDelegate { diff --git a/phoenix-ios/phoenix-ios/Assets.xcassets/user_round.imageset/Contents.json b/phoenix-ios/phoenix-ios/Assets.xcassets/user_round.imageset/Contents.json deleted file mode 100644 index 3da22dbc9..000000000 --- a/phoenix-ios/phoenix-ios/Assets.xcassets/user_round.imageset/Contents.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "images" : [ - { - "filename" : "user-round.svg", - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/phoenix-ios/phoenix-ios/Assets.xcassets/user_round.imageset/user-round.svg b/phoenix-ios/phoenix-ios/Assets.xcassets/user_round.imageset/user-round.svg deleted file mode 100644 index 4ea27d973..000000000 --- a/phoenix-ios/phoenix-ios/Assets.xcassets/user_round.imageset/user-round.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/phoenix-ios/phoenix-ios/Assets.xcassets/user_round_symbol.symbolset/Contents.json b/phoenix-ios/phoenix-ios/Assets.xcassets/user_round_symbol.symbolset/Contents.json deleted file mode 100644 index 4a09894af..000000000 --- a/phoenix-ios/phoenix-ios/Assets.xcassets/user_round_symbol.symbolset/Contents.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - }, - "symbols" : [ - { - "filename" : "user-round.SFSymbol.svg", - "idiom" : "universal" - } - ] -} diff --git a/phoenix-ios/phoenix-ios/Assets.xcassets/user_round_symbol.symbolset/user-round.SFSymbol.svg b/phoenix-ios/phoenix-ios/Assets.xcassets/user_round_symbol.symbolset/user-round.SFSymbol.svg deleted file mode 100644 index 7d8a54c4f..000000000 --- a/phoenix-ios/phoenix-ios/Assets.xcassets/user_round_symbol.symbolset/user-round.SFSymbol.svg +++ /dev/null @@ -1,161 +0,0 @@ - - - - - - -<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-user-round"><circle cx="12" cy="8" r="5"/><path d="M20 21a8 8 0 0 0-16 0"/></svg> - - - - Weight/Scale Variations - Ultralight - Thin - Light - Regular - Medium - Semibold - Bold - Heavy - Black - - - - - - - - - - - Design Variations - Symbols are supported in up to nine weights and three scales. - For optimal layout with text and other symbols, vertically align - symbols with the adjacent text. - - - - - - Margins - Leading and trailing margins on the left and right side of each symbol - can be adjusted by modifying the x-location of the margin guidelines. - Modifications are automatically applied proportionally to all - scales and weights. - - - - Exporting - Symbols should be outlined when exporting to ensure the - design is preserved when submitting to Xcode. - Template v.2.0 - Requires Xcode 12 or greater - Small - Medium - Large - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/phoenix-ios/phoenix-ios/Localizable.xcstrings b/phoenix-ios/phoenix-ios/Localizable.xcstrings index ebda6c17d..db436079a 100644 --- a/phoenix-ios/phoenix-ios/Localizable.xcstrings +++ b/phoenix-ios/phoenix-ios/Localizable.xcstrings @@ -1435,6 +1435,9 @@ } } } + }, + "**disabled**: sent payments will be anonymous" : { + }, "**Do not lose this seed.** Save it somewhere safe (not on this phone). If you lose your seed and your phone, you've lost your funds." : { "comment" : "RecoverySeedView", @@ -1517,6 +1520,9 @@ } } } + }, + "**enabled**: they will be able to tell when payments are from you" : { + }, "**Imagine that your wallet is a bucket**, and your balance is the water in the bucket." : { "localizations" : { @@ -1681,6 +1687,26 @@ } } }, + "%@ - from %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ - from %2$@" + } + } + } + }, + "%@ - to %@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ - to %2$@" + } + } + } + }, "%@ [more info](https://phoenix.acinq.co/faq#what-is-inbound-liquidity)" : { "localizations" : { "ar" : { @@ -2611,6 +2637,7 @@ }, "+(amount.string) inbound liquidity" : { "comment" : "Payment description for inbound liquidity", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -2685,6 +2712,9 @@ } } }, + "+%@ inbound liquidity" : { + "comment" : "Payment description for inbound liquidity" + }, "" : { "localizations" : { "ar" : { @@ -8392,6 +8422,12 @@ } } } + }, + "Bolt12 offer:" : { + + }, + "Bolt12 offers" : { + }, "broadcast" : { "localizations" : { @@ -14134,6 +14170,9 @@ } } } + }, + "Discard changes" : { + }, "Discard Changes" : { "localizations" : { @@ -14174,6 +14213,9 @@ } } } + }, + "Discard changes?" : { + }, "Disconnected" : { "localizations" : { @@ -15395,9 +15437,6 @@ } } } - }, - "Enable if you don't want Bolt12 recipients to know that payments come from your wallet." : { - }, "Enable notifications in the Notification Center" : { "localizations" : { @@ -21342,6 +21381,7 @@ }, "Invalid BIP353 DNS address" : { "comment" : "Error message - dns record contains an invalid offer", + "extractionState" : "stale", "localizations" : { "fr" : { "stringUnit" : { @@ -21507,6 +21547,9 @@ } } } + }, + "Invalid offer" : { + }, "Invalid port. Valid range: [1 - 65535]" : { "localizations" : { @@ -25309,6 +25352,9 @@ } } } + }, + "New contact" : { + }, "New inbound liquidity:" : { "localizations" : { @@ -25620,6 +25666,9 @@ } } } + }, + "No matches for search" : { + }, "No notifications" : { "localizations" : { @@ -26143,9 +26192,6 @@ } } } - }, - "Offer:" : { - }, "Offline" : { "comment" : "Connection state\nConnection status", @@ -27776,6 +27822,9 @@ } } } + }, + "Payment from %@" : { + }, "payment hash" : { "comment" : "Label in DetailsView_IncomingPayment", @@ -28503,6 +28552,9 @@ } } } + }, + "Payment to %@" : { + }, "Payment will fail" : { "localizations" : { @@ -30163,9 +30215,6 @@ } } } - }, - "Random payer key" : { - }, "Random server" : { "comment" : "Connection info", @@ -32049,6 +32098,9 @@ } } } + }, + "Search" : { + }, "Security" : { "extractionState" : "manual", @@ -32731,6 +32783,12 @@ } } } + }, + "Sent By" : { + + }, + "Sent To" : { + }, "Server" : { "localizations" : { @@ -38093,6 +38151,12 @@ } } }, + "This address is hosted on an unsecure DNS. DNSSEC must be enabled." : { + "comment" : "Error message - dns issue" + }, + "This address uses an invalid Bolt12 offer." : { + "comment" : "Error message - dns record contains an invalid offer" + }, "This appears to be a website (not a lightning invoice):" : { "localizations" : { "ar" : { @@ -40267,6 +40331,9 @@ } } } + }, + "Trusted contact" : { + }, "Trustless swaps" : { "localizations" : { @@ -45355,4 +45422,4 @@ } }, "version" : "1.0" -} +} \ No newline at end of file diff --git a/phoenix-ios/phoenix-ios/extensions/NSItemProvider+Async.swift b/phoenix-ios/phoenix-ios/extensions/NSItemProvider+Async.swift new file mode 100644 index 000000000..ab45e64b1 --- /dev/null +++ b/phoenix-ios/phoenix-ios/extensions/NSItemProvider+Async.swift @@ -0,0 +1,87 @@ +import Foundation +import UIKit +import UniformTypeIdentifiers.UTType + +fileprivate let filename = "NSItemProvider" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +extension NSItemProvider { + + @available(iOS 16.0, *) + func asyncLoadDataRepresentation( + for contentType: UTType + ) async throws -> Data { + + return try await withCheckedThrowingContinuation { continuation in + let _ = self.loadDataRepresentation(for: contentType) { data, error in + if let error { + continuation.resume(throwing: error) + } else if let data { + continuation.resume(returning: data) + } else { + preconditionFailure("NSItemProvider.loadDataRepresentation: failed API contract") + } + } + } + } + + @available(iOS 16.0, *) + func asyncLoadFileRepresentation( + for contentType: UTType, + openInPlace: Bool = false + ) async throws -> URL { + + return try await withCheckedThrowingContinuation { continuation in + let _ = self.loadFileRepresentation(for: contentType, openInPlace: openInPlace) { + (url: URL?, openedInPlace: Bool, error: (any Error)?) in + + if let error { + continuation.resume(throwing: error) + } else if let url { + if openedInPlace { + // Since file was successfully opened in place, + // we should (as far as I understand) have access to the URL outside this block. + continuation.resume(returning: url) + } else { + // We have to copy the file to a safe location + // because it will be deleted when we return from this block. + let tempDir = URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + let fileName = UUID().uuidString + let copyUrl = tempDir.appendingPathComponent(fileName, isDirectory: false) + do { + try FileManager.default.copyItem(at: url, to: copyUrl) + continuation.resume(returning: copyUrl) + } catch { + continuation.resume(throwing: error) + } + } + } else { + preconditionFailure("NSItemProvider.loadFileRepresentation: failed API contract") + } + } + } + } + + func asyncLoadImage() async throws -> UIImage? { + + return try await withCheckedThrowingContinuation { continuation in + guard self.canLoadObject(ofClass: UIImage.self) else { + continuation.resume(returning: nil) + return + } + let _ = self.loadObject(ofClass: UIImage.self) { image, error in + if let error { + continuation.resume(throwing: error) + } else if let image = image as? UIImage { + continuation.resume(returning: image) + } else { + preconditionFailure("NSItemProvider.loadObject: failed API contract") + } + } + } + } +} diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift index fed4c30c7..625d98e06 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Payments.swift @@ -1,6 +1,34 @@ import Foundation import PhoenixShared +extension PaymentsPage { + + func forceRefresh() -> PaymentsPage { + + // What we want to do is ensure a fetch (via the PaymentsFetcher) will return + // a new version of the payment (+ metadata + contact). + // To accomplish this we simply tweak `metadataModifiedAt` for each item. + + let newRows: [WalletPaymentOrderRow] = self.rows.map { (row: WalletPaymentOrderRow) in + + let newMetadataModifiedAt: Int64 + if let oldMetadataModifiedAt = row.metadataModifiedAt { + newMetadataModifiedAt = oldMetadataModifiedAt.int64Value + 1 + } else { + newMetadataModifiedAt = 0 + } + + return WalletPaymentOrderRow( + id: row.kotlinId(), + createdAt: row.createdAt, + completedAt: row.completedAt, + metadataModifiedAt: KotlinLong(value: newMetadataModifiedAt) + ) + } + + return PaymentsPage(offset: self.offset, count: self.count, rows: newRows) + } +} extension WalletPaymentOrderRow { @@ -28,7 +56,34 @@ extension WalletPaymentOrderRow { extension WalletPaymentInfo { - func paymentDescription(includingUserDescription: Bool = true) -> String? { + struct PaymentDescriptionOptions: OptionSet, CustomStringConvertible { + + let rawValue: Int + + static let userDescription = PaymentDescriptionOptions(rawValue: 1 << 0) + static let incomingBolt12Message = PaymentDescriptionOptions(rawValue: 1 << 1) + static let knownContact = PaymentDescriptionOptions(rawValue: 1 << 2) + + static let all: PaymentDescriptionOptions = [.userDescription, .incomingBolt12Message, .knownContact] + static let none: PaymentDescriptionOptions = [] + + var description: String { + var items = [String]() + items.reserveCapacity(3) + if contains(.userDescription) { + items.append("userDescription") + } + if contains(.incomingBolt12Message) { + items.append("incomingBolt12Message") + } + if contains(.knownContact) { + items.append("knownContact") + } + return "[\(items.joined(separator: ","))]" + } + } + + func paymentDescription(options: PaymentDescriptionOptions = .all) -> String? { let sanitize = { (input: String?) -> String? in @@ -41,13 +96,27 @@ extension WalletPaymentInfo { return nil } - if includingUserDescription { - if let description = sanitize(metadata.userDescription) { - return description + if options.contains(.userDescription) { + if let result = sanitize(metadata.userDescription) { + return result + } + } + if let contact { + if options.contains(.incomingBolt12Message) { + if payment.isIncoming(), let msg = attachedMessage() { + return msg // only incoming messages from **known contacts** + } + } + if options.contains(.knownContact) { + if payment.isIncoming() { + return String(localized: "Payment from \(contact.name)") + } else { + return String(localized: "Payment to \(contact.name)") + } } } - if let description = sanitize(metadata.lnurl?.description_) { - return description + if let result = sanitize(metadata.lnurl?.description_) { + return result } if let incomingPayment = payment as? Lightning_kmpIncomingPayment { @@ -58,10 +127,11 @@ extension WalletPaymentInfo { } else if let outgoingPayment = payment as? Lightning_kmpOutgoingPayment { - if let lightningPayment = payment as? Lightning_kmpLightningOutgoingPayment { + if let lightningPayment = outgoingPayment as? Lightning_kmpLightningOutgoingPayment { if let normal = lightningPayment.details.asNormal() { return sanitize(normal.paymentRequest.desc) + } else if let swapOut = lightningPayment.details.asSwapOut() { return sanitize(swapOut.address) } @@ -79,29 +149,29 @@ extension WalletPaymentInfo { if let incomingPayment = payment as? Lightning_kmpIncomingPayment { if let _ = incomingPayment.origin.asSwapIn() { - return NSLocalizedString("On-chain deposit", comment: "Payment description for received deposit") + return String(localized: "On-chain deposit", comment: "Payment description for received deposit") } else if let _ = incomingPayment.origin.asOnChain() { - return NSLocalizedString("On-chain deposit", comment: "Payment description for received deposit") + return String(localized: "On-chain deposit", comment: "Payment description for received deposit") } } else if let outgoingPayment = payment as? Lightning_kmpOutgoingPayment { if let _ = outgoingPayment as? Lightning_kmpChannelCloseOutgoingPayment { - return NSLocalizedString("Channel closing", comment: "Payment description for channel closing") + return String(localized: "Channel closing", comment: "Payment description for channel closing") } else if let _ = outgoingPayment as? Lightning_kmpSpliceCpfpOutgoingPayment { - return NSLocalizedString("Bump fees", comment: "Payment description for splice CPFP") + return String(localized: "Bump fees", comment: "Payment description for splice CPFP") } else if let il = outgoingPayment as? Lightning_kmpInboundLiquidityOutgoingPayment { let amount = Utils.formatBitcoin(sat: il._lease.amount, bitcoinUnit: .sat) - return NSLocalizedString( - "+\(amount.string) inbound liquidity", + return String( + localized: "+\(amount.string) inbound liquidity", comment: "Payment description for inbound liquidity" ) } } - return NSLocalizedString("No description", comment: "placeholder text") + return String(localized: "No description", comment: "placeholder text") } func attachedMessage() -> String? { diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinTypes.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinTypes.swift index 378b04a02..d68ed93ea 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinTypes.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinTypes.swift @@ -69,7 +69,8 @@ extension Scan { typealias BadRequestReason_ServiceError = BadRequestReasonServiceError typealias BadRequestReason_UnknownFormat = BadRequestReasonUnknownFormat typealias BadRequestReason_UnsupportedLnurl = BadRequestReasonUnsupportedLnurl - typealias BadRequestReason_InvalidBip353 = BadRequestReasonInvalidBip353 + typealias BadRequestReason_Bip353InvalidOffer = BadRequestReasonBip353InvalidOffer + typealias BadRequestReason_Bip353NoDNSSEC = BadRequestReasonBip353NoDNSSEC typealias ClipboardContent_Bolt11InvoiceRequest = ClipboardContentBolt11InvoiceRequest typealias ClipboardContent_BitcoinRequest = ClipboardContentBitcoinRequest diff --git a/phoenix-ios/phoenix-ios/officers/PhotosManager.swift b/phoenix-ios/phoenix-ios/officers/PhotosManager.swift new file mode 100644 index 000000000..62b53205f --- /dev/null +++ b/phoenix-ios/phoenix-ios/officers/PhotosManager.swift @@ -0,0 +1,196 @@ +import SwiftUI + +fileprivate let filename = "PhotosManager" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +class PhotosManager { + + /// Singleton instance + public static let shared = PhotosManager() + + private let queue = DispatchQueue.init(label: "PhotosManager") + private let cache = Cache(countLimit: 30) + + private let memoryPressure: DispatchSourceMemoryPressure + + private init() { // must use shared instance + + memoryPressure = DispatchSource.makeMemoryPressureSource( + eventMask: [.warning, .critical], + queue: queue + ) + memoryPressure.setEventHandler { [weak self] in + guard let self else { + return + } + let event = self.memoryPressure.data + switch event { + case .warning : self.respondToMemoryPressure(event) + case.critical : self.respondToMemoryPressure(event) + default : break + } + } + memoryPressure.activate() + } + + // -------------------------------------------------- + // MARK: Notifications + // -------------------------------------------------- + + func respondToMemoryPressure(_ event: DispatchSource.MemoryPressureEvent) { + log.trace("respondToMemoryPressure()") + + // Note: This function is invoked on our `queue`, so we can safely modify the `cache` variable. + + cache.removeAll() + } + + // -------------------------------------------------- + // MARK: Locations + // -------------------------------------------------- + + lazy var photosDirectory: URL = { + + // lazy == thread-safe (uses dispatch_once primitives internally) + + let fm = FileManager.default + + guard + let appSupportDir = fm.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + else { + fatalError("FileManager returned nil appSupportDir !") + } + + let photosDir = appSupportDir.appendingPathComponent("photos", isDirectory: true) + do { + try fm.createDirectory(at: photosDir, withIntermediateDirectories: true) + } catch { + fatalError("Error creating photos directory: \(error)") + } + + return photosDir + }() + + func urlForPhoto(fileName: String) -> URL { + + return photosDirectory.appendingPathComponent(fileName, isDirectory: false) + } + + func filePathForPhoto(fileName: String) -> String { + + return urlForPhoto(fileName: fileName).path + } + + // -------------------------------------------------- + // MARK: Writing + // -------------------------------------------------- + + enum PhotosManagerError: Error { + case writingToDisk + case copyingFile + case compressionFailed + } + + func writeToDisk(_ original: PickerResult) async throws -> String { + + let fileName = UUID().uuidString.replacingOccurrences(of: "-", with: "") + let fileUrl = self.urlForPhoto(fileName: fileName) + + let scaled = await original.downscale() + if let compressedImageData = await scaled.compress() { + + do { + try compressedImageData.write(to: fileUrl) + log.debug("compressedImage: \(fileUrl)") + + return fileName + } catch { + throw PhotosManagerError.writingToDisk + } + + } else if let originalFileUrl = scaled.file?.url { + + do { + try FileManager.default.copyItem(at: originalFileUrl, to: fileUrl) + log.debug("originalImage: \(originalFileUrl)") + + return fileName + } catch { + throw PhotosManagerError.copyingFile + } + + } else { + + log.debug("compression failed") + throw PhotosManagerError.compressionFailed + } + } + + func deleteFromDisk(fileName: String) async throws { + + return try await withCheckedThrowingContinuation { continuation in + DispatchQueue.global(qos: .userInitiated).async { + + let fileUrl = self.urlForPhoto(fileName: fileName) + do { + try FileManager.default.removeItem(at: fileUrl) + continuation.resume(with: .success) + } catch { + continuation.resume(throwing: error) + } + } + } + } + + // -------------------------------------------------- + // MARK: Reading + // -------------------------------------------------- + + func readFromDisk(fileName: String, size: CGFloat, useCache: Bool = true) async -> UIImage? { + + let readTask = {() -> UIImage? in + do { + let fileUrl = self.urlForPhoto(fileName: fileName) + let data = try Data(contentsOf: fileUrl, options: [.mappedIfSafe, .uncached]) + guard let fullSizePhoto = UIImage(data: data) else { + return nil + } + + let cgsize = CGSize(width: size, height: size) + guard let scaledPhoto = fullSizePhoto.preparingThumbnail(of: cgsize) else { + return nil + } + + return scaledPhoto + } catch { + log.warning("readFromDisk: error: \(error)") + return nil + } + } + + return await withCheckedContinuation { continuation in + if useCache { + queue.async { + let key = "\(fileName)|\(size)" + if let cachedImg = self.cache[key] { + continuation.resume(returning: cachedImg) + } else if let img = readTask() { + self.cache[key] = img + continuation.resume(returning: img) + } else { + continuation.resume(returning: nil) + } + } + } else { + DispatchQueue.global(qos: .userInitiated).async { + let img = readTask() + continuation.resume(returning: img) + } + } + } + } +} diff --git a/phoenix-ios/phoenix-ios/prefs/Prefs.swift b/phoenix-ios/phoenix-ios/prefs/Prefs.swift index 67110d766..b2fabbf60 100644 --- a/phoenix-ios/phoenix-ios/prefs/Prefs.swift +++ b/phoenix-ios/phoenix-ios/prefs/Prefs.swift @@ -25,7 +25,6 @@ fileprivate enum Key: String { case serverMessageReadIndex case allowOverpayment case doNotShowChannelImpactWarning - case randomPayerKey } fileprivate enum KeyDeprecated: String { @@ -173,14 +172,9 @@ class Prefs { } var doNotShowChannelImpactWarning: Bool { - get { defaults.doNotShowChannelImpactWarning } - set { defaults.doNotShowChannelImpactWarning = newValue } - } - - var randomPayerKey: Bool { - get { defaults.randomPayerKey } - set { defaults.randomPayerKey = newValue } - } + get { defaults.doNotShowChannelImpactWarning } + set { defaults.doNotShowChannelImpactWarning = newValue } + } // -------------------------------------------------- // MARK: Wallet State @@ -269,7 +263,6 @@ class Prefs { defaults.removeObject(forKey: Key.serverMessageReadIndex.rawValue) defaults.removeObject(forKey: Key.allowOverpayment.rawValue) defaults.removeObject(forKey: Key.doNotShowChannelImpactWarning.rawValue) - defaults.removeObject(forKey: Key.randomPayerKey.rawValue) self.backupTransactions.resetWallet(encryptedNodeId: encryptedNodeId) self.backupSeed.resetWallet(encryptedNodeId: encryptedNodeId) @@ -389,9 +382,4 @@ extension UserDefaults { get { bool(forKey: Key.doNotShowChannelImpactWarning.rawValue) } set { set(newValue, forKey: Key.doNotShowChannelImpactWarning.rawValue) } } - - @objc fileprivate var randomPayerKey: Bool { - get { bool(forKey: Key.randomPayerKey.rawValue) } - set { set(newValue, forKey: Key.randomPayerKey.rawValue) } - } } diff --git a/phoenix-ios/phoenix-ios/utils/Cache.swift b/phoenix-ios/phoenix-ios/utils/Cache.swift index dc0b5d395..370b0d690 100644 --- a/phoenix-ios/phoenix-ios/utils/Cache.swift +++ b/phoenix-ios/phoenix-ios/utils/Cache.swift @@ -230,7 +230,14 @@ class Cache { } } - func removeValue(forKey key: Key) -> Void { + func removeAll() { + + mostRecentCacheItem = nil + leastRecentCacheItem = nil + _dict.removeAll(keepingCapacity: true) + } + + func removeValue(forKey key: Key) { if let item = _dict[key] { diff --git a/phoenix-ios/phoenix-ios/views/compatibility/ScrollingDismissesKeyboard.swift b/phoenix-ios/phoenix-ios/views/compatibility/ScrollingDismissesKeyboard.swift new file mode 100644 index 000000000..bf5520025 --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/compatibility/ScrollingDismissesKeyboard.swift @@ -0,0 +1,41 @@ +import SwiftUI + +enum ScrollingDismissesKeyboardMode { + case automatic + case immediately + case interactively + case never + + @available(iOS 16.0, *) + func convert() -> ScrollDismissesKeyboardMode { + switch self { + case .automatic : return ScrollDismissesKeyboardMode.automatic + case .immediately : return ScrollDismissesKeyboardMode.immediately + case .interactively : return ScrollDismissesKeyboardMode.interactively + case .never : return ScrollDismissesKeyboardMode.never + } + } +} + +struct ScrollingDismissesKeyboard: ViewModifier { + let mode: ScrollingDismissesKeyboardMode + + @ViewBuilder + func body(content: Content) -> some View { + if #available(iOS 16.0, *) { + content + .scrollDismissesKeyboard(mode.convert()) + } else { + content + } + } +} + +extension View { + func scrollingDismissesKeyboard(_ mode: ScrollingDismissesKeyboardMode) -> some View { + ModifiedContent( + content: self, + modifier: ScrollingDismissesKeyboard(mode: mode) + ) + } +} diff --git a/phoenix-ios/phoenix-ios/views/configuration/general/display configuration/DisplayConfigurationView.swift b/phoenix-ios/phoenix-ios/views/configuration/general/display configuration/DisplayConfigurationView.swift index 8f3d81381..e2de120d8 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/general/display configuration/DisplayConfigurationView.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/general/display configuration/DisplayConfigurationView.swift @@ -232,21 +232,3 @@ fileprivate struct DisplayConfigurationList: View { } } } - -// MARK: - - -class DisplayConfigurationView_Previews: PreviewProvider { - - static var previews: some View { - - DisplayConfigurationView() - .preferredColorScheme(.light) - .previewDevice("iPhone 11") - .environmentObject(CurrencyPrefs.mockEUR()) - - DisplayConfigurationView() - .preferredColorScheme(.dark) - .previewDevice("iPhone 11") - .environmentObject(CurrencyPrefs.mockEUR()) - } -} diff --git a/phoenix-ios/phoenix-ios/views/configuration/general/payment options/PaymentOptionsView.swift b/phoenix-ios/phoenix-ios/views/configuration/general/payment options/PaymentOptionsView.swift index 4aa22261b..a81f8b97e 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/general/payment options/PaymentOptionsView.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/general/payment options/PaymentOptionsView.swift @@ -34,7 +34,6 @@ fileprivate struct PaymentOptionsList: View { let invoiceExpirationDaysOptions = [7, 30, 60] @State var allowOverpayment: Bool = Prefs.shared.allowOverpayment - @State var randomPayerKey: Bool = Prefs.shared.randomPayerKey @State var notificationSettings = NotificationsManager.shared.settings.value @@ -107,7 +106,6 @@ fileprivate struct PaymentOptionsList: View { Section { subsection_enableOverpayments() - subsection_randomPayerKey() } /* Section.*/header: { Text("Outgoing payments") @@ -272,44 +270,6 @@ fileprivate struct PaymentOptionsList: View { } // } - @ViewBuilder - func subsection_randomPayerKey() -> some View { - - HStack(alignment: VerticalAlignment.centerTopLine) { // <- Custom VerticalAlignment - - VStack(alignment: HorizontalAlignment.leading, spacing: 8) { - Text("Random payer key") - .alignmentGuide(VerticalAlignment.centerTopLine) { (d: ViewDimensions) in - d[VerticalAlignment.center] - } - - Text( - """ - Enable if you don't want Bolt12 recipients to know \ - that payments come from your wallet. - """ - ) - .font(.callout) - .fixedSize(horizontal: false, vertical: true) // SwiftUI truncation bugs - .foregroundColor(Color.secondary) - - } // - - Spacer() - - Toggle("", isOn: $randomPayerKey) - .labelsHidden() - .padding(.trailing, 2) - .alignmentGuide(VerticalAlignment.centerTopLine) { (d: ViewDimensions) in - d[VerticalAlignment.center] - } - .onChange(of: randomPayerKey) { newValue in - Prefs.shared.randomPayerKey = newValue - } - - } // - } - @ViewBuilder private func navLink( _ tag: NavLinkTag, diff --git a/phoenix-ios/phoenix-ios/views/contacts/ContactPhoto.swift b/phoenix-ios/phoenix-ios/views/contacts/ContactPhoto.swift new file mode 100644 index 000000000..c794f1609 --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/contacts/ContactPhoto.swift @@ -0,0 +1,106 @@ +import SwiftUI +import PhoenixShared + +fileprivate let filename = "ContactPhoto" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct ContactPhoto: View { + + let fileName: String? + let size: CGFloat + let useCache: Bool + + init(fileName: String?, size: CGFloat, useCache: Bool = true) { + self.fileName = fileName + self.size = size + self.useCache = useCache + + log.trace("[public] init(): \(fileName ?? "")") + } + + @ViewBuilder + var body: some View { + + _ContactPhoto(fileName: fileName, size: size, useCache: useCache) + .id(uniqueId) // <- required + + // Due to "structural identity" in SwiftUI: + // - even when `fileName` or `size` changes, it's still considered to be the "same" view + // - which means we don't receive a notification via `onAppear` + // - nor do our `.task` items re-fire + // + // In other words: + // - zero notifications + // - only a silent re-run of our ViewBuilder `body` + // + // To get around this, we use `.id` to force "explicit identity". + // So when `fileName` or `size` changes, it will be a new instance of `_ContactPhoto`. + } + + var uniqueId: String { + return "\(fileName ?? "")@\(size)|\(useCache)" + } +} + +fileprivate struct _ContactPhoto: View { + + let fileName: String? + let size: CGFloat + let useCache: Bool + + @State private var bgLoadedImage: UIImage? = nil + + @Environment(\.displayScale) var displayScale: CGFloat + + init(fileName: String?, size: CGFloat, useCache: Bool) { + self.fileName = fileName + self.size = size + self.useCache = useCache + + log.trace("[private] init(): \(fileName ?? "")") + } + + @ViewBuilder + var body: some View { + + Group { + if let uiImage = bgLoadedImage { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(contentMode: .fill) // FILL ! + } else { + Image(systemName: "person.circle") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.gray) + } + } + .frame(width: size, height: size) + .clipShape(Circle()) + .task { + await loadImage() + } + } + + func loadImage() async { + log.trace("[private] loadImage(): \(fileName ?? "")") + + guard let fileName else { + return + } + + let targetSize = size * displayScale + let img = await PhotosManager.shared.readFromDisk( + fileName: fileName, + size: targetSize, + useCache: useCache + ) + + log.trace("[private] loadImage(): \(fileName) => done") + bgLoadedImage = img + } +} diff --git a/phoenix-ios/phoenix-ios/views/contacts/ContactsList.swift b/phoenix-ios/phoenix-ios/views/contacts/ContactsList.swift index 25d2c0f88..97de33161 100644 --- a/phoenix-ios/phoenix-ios/views/contacts/ContactsList.swift +++ b/phoenix-ios/phoenix-ios/views/contacts/ContactsList.swift @@ -11,8 +11,16 @@ fileprivate var log = LoggerFactory.shared.logger(filename, .warning) struct ContactsList: View { @State var sortedContacts: [ContactInfo] = [] + @State var offers: [String: [String]] = [:] @State var searchText = "" + @State var filteredContacts: [ContactInfo]? = nil + + @State var addItem: Bool = false + @State var selectedItem: ContactInfo? = nil + @State var pendingDelete: ContactInfo? = nil + + @EnvironmentObject var smartModalState: SmartModalState // -------------------------------------------------- // MARK: View Builders @@ -21,54 +29,170 @@ struct ContactsList: View { @ViewBuilder var body: some View { - content() - .navigationTitle("Address Book") - .navigationBarTitleDisplayMode(.inline) - .navigationBarItems(trailing: plusButton()) + ZStack { + + if #unavailable(iOS 16.0) { + NavigationLink( + destination: selectedItemView(), + isActive: selectedItemBinding() + ) { + EmptyView() + } + .isDetailLink(false) + } // else: uses.navigationStackDestination() + + content() + } + .navigationStackDestination(isPresented: selectedItemBinding()) { // For iOS 16+ + selectedItemView() + } + .navigationTitle("Address Book") + .navigationBarTitleDisplayMode(.inline) + .navigationBarItems(trailing: plusButton()) } @ViewBuilder func content() -> some View { + list() + .onReceive(Biz.business.contactsManager.contactsListPublisher()) { + contactsListChanged($0) + } + .onChange(of: searchText) { _ in + searchTextChanged() + } + + } + + @ViewBuilder + func list() -> some View { + List { - ForEach(sortedContacts) { item in + ForEach(visibleContacts) { item in Button { - didSelectItem(item) + selectedItem = item } label: { - row(item: item) + row(item) } + .swipeActions(allowsFullSwipe: false) { + Button { + selectedItem = item + } label: { + Label("Edit", systemImage: "square.and.pencil") + } + Button { + pendingDelete = item // prompt for confirmation + } label: { + Label("Delete", systemImage: "trash.fill") + } + .tint(.red) + } + } + if hasZeroMatchesForSearch { + zeroMatches() + .deleteDisabled(true) } } // .listStyle(.plain) - .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always)) - .onReceive(Biz.business.contactsManager.contactsListPublisher()) { - contactsListChanged($0) + .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .automatic)) + .confirmationDialog("Delete contact?", + isPresented: confirmationDialogBinding(), + titleVisibility: Visibility.automatic + ) { + Button("Delete contact", role: ButtonRole.destructive) { + deleteContact() + } } } @ViewBuilder - func row(item: ContactInfo) -> some View { + func row(_ item: ContactInfo) -> some View { - HStack(alignment: VerticalAlignment.center, spacing: 0) { + HStack(alignment: VerticalAlignment.center, spacing: 8) { + ContactPhoto(fileName: item.photoUri, size: 32) Text(item.name) - .font(.title3.bold()) + .font(.title3) .foregroundColor(.primary) Spacer() } - .padding(.horizontal) - .padding(.vertical, 8) + .padding(.all, 4) + } + + @ViewBuilder + func zeroMatches() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("No matches for search").foregroundStyle(.secondary) + Spacer() + } + .padding(.all, 4) + } + + @ViewBuilder + func selectedItemView() -> some View { + + if let selectedItem { + ManageContact( + location: .embedded, + offer: nil, + contact: selectedItem, + contactUpdated: { _ in } + ) + } else if addItem { + ManageContact( + location: .embedded, + offer: nil, + contact: nil, + contactUpdated: { _ in } + ) + } else { + EmptyView() + } } @ViewBuilder func plusButton() -> some View { Button { - // proof-of-concept + addItem = true } label: { Image(systemName: "plus") } } + // -------------------------------------------------- + // MARK: View Helpers + // -------------------------------------------------- + + var visibleContacts: [ContactInfo] { + return filteredContacts ?? sortedContacts + } + + var hasZeroMatchesForSearch: Bool { + + guard let filteredContacts else { + return false + } + + return filteredContacts.isEmpty && !sortedContacts.isEmpty + } + + func selectedItemBinding() -> Binding { + + return Binding( + get: { selectedItem != nil || addItem }, + set: { if !$0 { selectedItem = nil; addItem = false }} + ) + } + + func confirmationDialogBinding() -> Binding { + + return Binding( + get: { pendingDelete != nil }, + set: { if !$0 { pendingDelete = nil }} + ) + } + // -------------------------------------------------- // MARK: Notifications // -------------------------------------------------- @@ -77,21 +201,77 @@ struct ContactsList: View { log.trace("contactsListChanged()") sortedContacts = updatedList + + var updatedOffers: [String: [String]] = [:] + for contact in sortedContacts { + let key: String = contact.id + let values: [String] = contact.offers.map { $0.encode().lowercased() } + + updatedOffers[key] = values + } + + offers = updatedOffers + } + + func searchTextChanged() { + log.trace("searchTextChanged: \(searchText)") + + guard !searchText.isEmpty else { + filteredContacts = nil + return + } + + let searchtext = searchText.lowercased() + filteredContacts = sortedContacts.filter { (contact: ContactInfo) in + if contact.name.localizedCaseInsensitiveContains(searchtext) { + return true + } + + if let offers = offers[contact.id] { + if offers.contains(searchtext) { + return true + } + } + + return false + } } // -------------------------------------------------- // MARK: Actions // -------------------------------------------------- - func addContact() { - log.trace("addContact()") + func deleteContact() { + log.trace("deleteContact: \(pendingDelete?.name ?? "")") - // Todo... + guard let contact = pendingDelete else { + return + } + + Task { @MainActor in + + let contactsManager = Biz.business.contactsManager + do { + try await contactsManager.deleteContact(contactId: contact.uuid) + } catch { + log.error("contactsManager.deleteContact(): error: \(error)") + } + } } - func didSelectItem(_ item: ContactInfo) { - log.trace("didSelectItem: \(item.name)") + // -------------------------------------------------- + // MARK: Utilities + // -------------------------------------------------- + + func dismissKeyboardIfVisible() -> Void { + log.trace("dismissKeyboardIfVisible()") - // Todo... + let keyWindow = UIApplication.shared.connectedScenes + .filter({ $0.activationState == .foregroundActive }) + .map({ $0 as? UIWindowScene }) + .compactMap({ $0 }) + .first?.windows + .filter({ $0.isKeyWindow }).first + keyWindow?.endEditing(true) } } diff --git a/phoenix-ios/phoenix-ios/views/contacts/ContactsListSheet.swift b/phoenix-ios/phoenix-ios/views/contacts/ContactsListSheet.swift new file mode 100644 index 000000000..f05106672 --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/contacts/ContactsListSheet.swift @@ -0,0 +1,242 @@ +import SwiftUI +import PhoenixShared + +fileprivate let filename = "ContactsListSheet" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct ContactsListSheet: View { + + let didSelectContact: (ContactInfo) -> Void + + @State var sortedContacts: [ContactInfo] = [] + @State var offers: [String: [String]] = [:] + + @State var searchText = "" + @State var filteredContacts: [ContactInfo]? = nil + + @EnvironmentObject var deviceInfo: DeviceInfo + @EnvironmentObject var smartModalState: SmartModalState + + // -------------------------------------------------- + // MARK: View Builders + // -------------------------------------------------- + + @ViewBuilder + var body: some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 0) { + header() + content() + .frame(maxHeight: (deviceInfo.windowSize.height / 2.0)) + } + } + + @ViewBuilder + func header() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("Contacts") + .font(.title3) + .accessibilityAddTraits(.isHeader) + .accessibilitySortPriority(100) + Spacer() + Button { + closeSheet() + } label: { + Image(systemName: "xmark").imageScale(.medium).font(.title2) + } + .accessibilityLabel("Close") + .accessibilityHidden(smartModalState.dismissable) + } + .padding(.horizontal) + .padding(.vertical, 8) + .background( + Color(UIColor.secondarySystemBackground) + .cornerRadius(15, corners: [.topLeft, .topRight]) + ) + .padding(.bottom, 4) + } + + @ViewBuilder + func content() -> some View { + + list() + .onReceive(Biz.business.contactsManager.contactsListPublisher()) { + contactsListChanged($0) + } + .onChange(of: searchText) { _ in + searchTextChanged() + } + } + + @ViewBuilder + func list() -> some View { + + List { + searchField() + ForEach(visibleContacts) { item in + Button { + selectItem(item) + } label: { + row(item) + } + } + if hasZeroMatchesForSearch { + zeroMatches() + .deleteDisabled(true) + } + } // + .listStyle(.plain) + } + + @ViewBuilder + func searchField() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + + Image(systemName: "magnifyingglass") + .foregroundColor(.gray) + .padding(.trailing, 4) + + TextField("Search", text: $searchText) + + // Clear button (appears when TextField's text is non-empty) + Button { + searchText = "" + } label: { + Image(systemName: "multiply.circle.fill") + .foregroundColor(.secondary) + } + .accessibilityLabel("Clear textfield") + .isHidden(searchText == "") + } + .padding(.all, 8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.textFieldBorder, lineWidth: 1) + ) + .padding(.horizontal, 4) + } + + @ViewBuilder + func row(_ item: ContactInfo) -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 8) { + ContactPhoto(fileName: item.photoUri, size: 32) + Text(item.name) + .font(.title3) + .foregroundColor(.primary) + Spacer() + } + .padding(.all, 4) + } + + @ViewBuilder + func zeroMatches() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("No matches for search").foregroundStyle(.secondary) + Spacer() + } + .padding(.all, 4) + } + + // -------------------------------------------------- + // MARK: View Helpers + // -------------------------------------------------- + + var visibleContacts: [ContactInfo] { + return filteredContacts ?? sortedContacts + } + + var hasZeroMatchesForSearch: Bool { + + guard let filteredContacts else { + return false + } + + return filteredContacts.isEmpty && !sortedContacts.isEmpty + } + + // -------------------------------------------------- + // MARK: Notifications + // -------------------------------------------------- + + func contactsListChanged(_ updatedList: [ContactInfo]) { + log.trace("contactsListChanged()") + + sortedContacts = updatedList + + var updatedOffers: [String: [String]] = [:] + for contact in sortedContacts { + let key: String = contact.id + let values: [String] = contact.offers.map { $0.encode().lowercased() } + + updatedOffers[key] = values + } + + offers = updatedOffers + } + + func searchTextChanged() { + log.trace("searchTextChanged: \(searchText)") + + guard !searchText.isEmpty else { + filteredContacts = nil + return + } + + let searchtext = searchText.lowercased() + filteredContacts = sortedContacts.filter { (contact: ContactInfo) in + if contact.name.localizedCaseInsensitiveContains(searchtext) { + return true + } + + if let offers = offers[contact.id] { + if offers.contains(searchtext) { + return true + } + } + + return false + } + } + + // -------------------------------------------------- + // MARK: Actions + // -------------------------------------------------- + + func selectItem(_ item: ContactInfo) { + log.trace("selectItem: \(item.name)") + + didSelectContact(item) + smartModalState.close() + } + + func closeSheet() { + log.trace("closeSheet()") + + smartModalState.close() + } + + // -------------------------------------------------- + // MARK: Utilities + // -------------------------------------------------- + + func dismissKeyboardIfVisible() -> Void { + log.trace("dismissKeyboardIfVisible()") + + let keyWindow = UIApplication.shared.connectedScenes + .filter({ $0.activationState == .foregroundActive }) + .map({ $0 as? UIWindowScene }) + .compactMap({ $0 }) + .first?.windows + .filter({ $0.isKeyWindow }).first + keyWindow?.endEditing(true) + } +} + diff --git a/phoenix-ios/phoenix-ios/views/contacts/ManageContact.swift b/phoenix-ios/phoenix-ios/views/contacts/ManageContact.swift new file mode 100644 index 000000000..4f6f0ca5d --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/contacts/ManageContact.swift @@ -0,0 +1,954 @@ +import SwiftUI +import PhoenixShared + +fileprivate let filename = "ManageContact" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct OfferRow: Identifiable { + let offer: String + let isCurrentOffer: Bool + + var id: String { + return offer + } +} + +fileprivate let IMG_SIZE: CGFloat = 150 +fileprivate let DEFAULT_TRUSTED: Bool = true + +struct ManageContact: View { + + enum Location { + case smartModal + case sheet + case embedded + } + + let location: Location + + let offer: Lightning_kmpOfferTypesOffer? + let contact: ContactInfo? + let contactUpdated: (ContactInfo?) -> Void + let isNewContact: Bool + + @State var name: String + @State var trustedContact: Bool + @State var showImageOptions: Bool = false + @State var pickerResult: PickerResult? + @State var doNotUseDiskImage: Bool = false + + @State var isSaving: Bool = false + @State var showDiscardChangesConfirmationDialog: Bool = false + @State var showDeleteContactConfirmationDialog: Bool = false + + @State var showingOffers: Bool = false + @State var chevronPosition: AnimatedChevron.Position = .pointingDown + + @State var pastedOffer: String = "" + @State var pastedOfferIsInvalid: Bool = false + @State var parsedOffer: Lightning_kmpOfferTypesOffer? = nil + + @State var didAppear: Bool = false + + enum ActiveSheet { + case camera + case imagePicker + } + @State var activeSheet: ActiveSheet? = nil + + // For the footer buttons: [cancel, save] + enum MaxFooterButtonWidth: Preference {} + let maxFooterButtonWidthReader = GeometryPreferenceReader( + key: AppendValue.self, + value: { [$0.size.width] } + ) + @State var maxFooterButtonWidth: CGFloat? = nil + + @StateObject var toast = Toast() + + @Environment(\.colorScheme) var colorScheme: ColorScheme + @Environment(\.presentationMode) var presentationMode: Binding + + @EnvironmentObject var deviceInfo: DeviceInfo + @EnvironmentObject var smartModalState: SmartModalState + + // -------------------------------------------------- + // MARK: Init + // -------------------------------------------------- + + init( + location: Location, + offer: Lightning_kmpOfferTypesOffer?, + contact: ContactInfo?, + contactUpdated: @escaping (ContactInfo?) -> Void + ) { + self.location = location + self.offer = offer + self.contact = contact + self.contactUpdated = contactUpdated + self.isNewContact = (contact == nil) + + self._name = State(initialValue: contact?.name ?? "") + self._trustedContact = State(initialValue: contact?.useOfferKey ?? DEFAULT_TRUSTED) + } + + // -------------------------------------------------- + // MARK: View Builders + // -------------------------------------------------- + + @ViewBuilder + var body: some View { + + switch location { + case .smartModal: + main() + + case .sheet: + main() + .navigationBarHidden(true) + + case .embedded: + main() + .navigationTitle(self.title) + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden(true) + .navigationBarItems(leading: header_backButton(), trailing: header_trailingButtons()) + .background( + Color.primaryBackground.ignoresSafeArea(.all, edges: .bottom) + ) + } + } + + @ViewBuilder + func main() -> some View { + + ZStack(alignment: Alignment.center) { + VStack(alignment: HorizontalAlignment.center, spacing: 0) { + header() + content() + footer() + } + toast.view() + } // + .onAppear { + onAppear() + } + .sheet(isPresented: activeSheetBinding()) { // SwiftUI only allows for 1 ".sheet" + switch activeSheet! { + case .camera: + CameraPicker(result: $pickerResult) + + case .imagePicker: + ImagePicker(copyFile: true, result: $pickerResult) + + } // + } + } + + @ViewBuilder + func header() -> some View { + + Group { + switch location { + case .smartModal: + header_smartModal() + + case .sheet: + header_sheet() + + case .embedded: + header_embedded() + } + } + .confirmationDialog("Discard changes?", + isPresented: $showDiscardChangesConfirmationDialog, + titleVisibility: Visibility.hidden + ) { + Button("Discard changes", role: ButtonRole.destructive) { + discardChanges() + } + } + .confirmationDialog("Delete contact?", + isPresented: $showDeleteContactConfirmationDialog, + titleVisibility: Visibility.hidden + ) { + Button("Delete contact", role: ButtonRole.destructive) { + deleteContact() + } + } + } + + @ViewBuilder + func header_smartModal() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text(self.title) + .font(.title3) + .accessibilityAddTraits(.isHeader) + + Spacer(minLength: 0) + + if !isNewContact { + Button { + showDeleteContactConfirmationDialog = true + } label: { + Image(systemName: "trash.fill") + .imageScale(.medium) + .font(.title2) + .foregroundColor(.appNegative) + } + .disabled(isSaving) + .accessibilityLabel("Delete contact") + } + } + .padding(.horizontal) + .padding(.vertical, 8) + .background( + Color(UIColor.secondarySystemBackground) + .cornerRadius(15, corners: [.topLeft, .topRight]) + ) + .padding(.bottom, 4) + } + + @ViewBuilder + func header_sheet() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + header_backButton() + Spacer() + header_trailingButtons() + } + .padding() + } + + @ViewBuilder + func header_embedded() -> some View { + + Spacer() + .frame(height: 25) + } + + @ViewBuilder + func header_backButton() -> some View { + + Button { + saveButtonTapped() + } label: { + HStack(alignment: .center, spacing: 4) { + Image(systemName: "chevron.backward") + .imageScale(.medium) + .font(.headline.weight(.semibold)) + if hasChanges() { + if canSave() { + Text("Save").font(.title3) + } else { + Text("Cancel").font(.title3) + } + } + } + } + .disabled(isSaving) + } + + @ViewBuilder + func header_trailingButtons() -> some View { + + if !isNewContact { + HStack(alignment: VerticalAlignment.center, spacing: 10) { + Button { + showDiscardChangesConfirmationDialog = true + } label: { + Image(systemName: "eraser") + .imageScale(.medium) + .font(.title2) + .foregroundColor(.gray) + } + .disabled(!hasChanges()) + .accessibilityLabel("Discard changes") + + Button { + showDeleteContactConfirmationDialog = true + } label: { + Image(systemName: "trash.fill") + .imageScale(.medium) + .font(.title2) + .foregroundColor(.appNegative) + } + .disabled(isSaving) + .accessibilityLabel("Delete contact") + } + } + } + + @ViewBuilder + func content() -> some View { + + ScrollView { + VStack(alignment: HorizontalAlignment.center, spacing: 0) { + + content_image() + content_name() + content_trusted() + if showOffers { + content_offers() + } + if showPasteOffer { + content_pasteOffer() + } + } // + .padding() + } // + .frame(maxHeight: scrollViewMaxHeight) + .scrollingDismissesKeyboard(.interactively) + } + + @ViewBuilder + func content_image() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Spacer(minLength: 0) + Group { + if useDiskImage && didAppear { + ContactPhoto(fileName: contact?.photoUri, size: IMG_SIZE, useCache: false) + } else if let uiimage = pickerResult?.image { + Image(uiImage: uiimage) + .resizable() + .aspectRatio(contentMode: .fill) // FILL ! + } else { + Image(systemName: "person.circle") + .resizable() + .aspectRatio(contentMode: .fit) + .foregroundColor(.gray) + } + } + .frame(width: IMG_SIZE, height: IMG_SIZE) + .clipShape(Circle()) + .onTapGesture { + if !isSaving { + showImageOptions = true + } + } + Spacer(minLength: 0) + } + .padding(.bottom) + .background(backgroundColor) + .zIndex(1) + .confirmationDialog("Contact Image", + isPresented: $showImageOptions, + titleVisibility: .automatic + ) { + Button { + selectImageOptionSelected() + activeSheet = .imagePicker + } label: { + Text("Select image") + } + Button { + takePhotoOptionSelected() + } label: { + Text("Take photo") + } + if hasImage { + Button("Clear image", role: ButtonRole.destructive) { + pickerResult = nil + doNotUseDiskImage = true + } + } + } // + } + + @ViewBuilder + func content_name() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + TextField("Name", text: $name) + .disabled(isSaving) + + // Clear button (appears when TextField's text is non-empty) + Button { + name = "" + } label: { + Image(systemName: "multiply.circle.fill") + .foregroundColor(Color(UIColor.tertiaryLabel)) + } + .disabled(isSaving) + .isHidden(name == "") + } + .padding(.all, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(UIColor.systemBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.textFieldBorder, lineWidth: 1) + ) + .padding(.bottom, 30) + .background(backgroundColor) + .zIndex(1) + } + + @ViewBuilder + func content_trusted() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 0) { + Toggle(isOn: $trustedContact) { + Text("Trusted contact") + } + .disabled(isSaving) + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 2) { + Text(verbatim: "•") + .font(.title2) + Text("**enabled**: they will be able to tell when payments are from you") + .font(.subheadline) + .fixedSize(horizontal: false, vertical: true) + } + .foregroundColor(.secondary) + + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 2) { + Text(verbatim: "•") + .font(.title2) + Text("**disabled**: sent payments will be anonymous") + .font(.subheadline) + .fixedSize(horizontal: false, vertical: true) + } + .foregroundColor(.secondary) + } + .padding(.bottom, 30) + .background(backgroundColor) + .zIndex(1) + } + + @ViewBuilder + func content_offers() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 0) { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text("Bolt12 offers") + Spacer(minLength: 0) + AnimatedChevron( + position: $chevronPosition, + color: Color(UIColor.systemGray2), + lineWidth: 20, + lineThickness: 2, + verticalOffset: 8 + ) + } // + .background(backgroundColor) + .contentShape(Rectangle()) // make Spacer area tappable + .onTapGesture { + withAnimation { + if showingOffers { + showingOffers = false + chevronPosition = .pointingDown + } else { + showingOffers = true + chevronPosition = .pointingUp + } + } + } + .zIndex(1) + + if showingOffers { + VStack(alignment: HorizontalAlignment.leading, spacing: 8) { + ForEach(offerRows()) { row in + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Text(row.offer) + .lineLimit(1) + .truncationMode(.middle) + .foregroundColor(row.isCurrentOffer ? Color.appPositive : Color.primary) + Spacer(minLength: 8) + Button { + copyText(row.offer) + } label: { + Image(systemName: "square.on.square") + } + } + .font(.subheadline) + .padding(.vertical, 8) + .padding(.leading, 20) + } // + } // + .zIndex(0) + .transition(.move(edge: .top).combined(with: .opacity)) + } + + } // + .padding(.bottom) + } + + @ViewBuilder + func content_pasteOffer() -> some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 0) { + Text("Bolt12 offer:") + .padding(.bottom, 4) + + TextEditor(text: $pastedOffer) + .frame(minHeight: 80, maxHeight: 80) + .padding(.all, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(UIColor.systemBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.textFieldBorder, lineWidth: 1) + ) + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Spacer() + if pastedOfferIsInvalid { + Text("Invalid offer") + .font(.subheadline) + .foregroundColor(.appNegative) + } else { + Text(verbatim: " ") + } + } + + } // + .padding(.bottom) + .onChange(of: pastedOffer) { _ in + pastedOfferChanged() + } + } + + @ViewBuilder + func footer() -> some View { + + if case .smartModal = location { + footer_smartModal() + } + } + + @ViewBuilder + func footer_smartModal() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + + Button { + cancelButtonTapped() + } label: { + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 2) { + Image(systemName: "xmark") + Text("Cancel") + } + .frame(width: maxFooterButtonWidth) + .read(maxFooterButtonWidthReader) + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .foregroundColor(hasName ? Color.appNegative : Color.appNegative.opacity(0.6)) + .disabled(isSaving) + + Spacer().frame(maxWidth: 16) + + Button { + saveButtonTapped() + } label: { + HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 2) { + Image(systemName: "checkmark") + Text("Save") + } + .frame(width: maxFooterButtonWidth) + .read(maxFooterButtonWidthReader) + } + .buttonStyle(.bordered) + .buttonBorderShape(.capsule) + .foregroundColor(hasName ? Color.appPositive : Color.appPositive.opacity(0.6)) + .disabled(isSaving || !canSave()) + + } // + .padding() + .assignMaxPreference(for: maxFooterButtonWidthReader.key, to: $maxFooterButtonWidth) + } + + // -------------------------------------------------- + // MARK: View Helpers + // -------------------------------------------------- + + func activeSheetBinding() -> Binding { + + return Binding( + get: { activeSheet != nil }, + set: { if !$0 { activeSheet = nil }} + ) + } + + var title: String { + + if isNewContact { + return String(localized: "New contact") + } else { + return String(localized: "Edit contact") + } + } + + var scrollViewMaxHeight: CGFloat { + + if case .smartModal = location { + if deviceInfo.isShortHeight { + return CGFloat.infinity + } else { + return deviceInfo.windowSize.height * 0.6 + } + } else { + return CGFloat.infinity + } + } + + var backgroundColor: Color { + switch location { + case .smartModal : return Color(UIColor.systemBackground) + case .sheet : return Color(UIColor.systemBackground) + case .embedded : return Color.primaryBackground + } + } + + var trimmedName: String { + return name.trimmingCharacters(in: .whitespacesAndNewlines) + } + + var hasName: Bool { + return !trimmedName.isEmpty + } + + var useDiskImage: Bool { + + if doNotUseDiskImage { + return false + } else if let _ = pickerResult { + return false + } else { + return true + } + } + + var hasImage: Bool { + + if doNotUseDiskImage { + return pickerResult != nil + } else if let _ = pickerResult { + return true + } else { + return contact?.photoUri != nil + } + } + + var showOffers: Bool { + + if offer != nil { + return true + } else if let contact { + return !contact.offers.isEmpty + } else { + return false + } + } + + var showPasteOffer: Bool { + + return (offer == nil) && (contact == nil) + } + + func offerRows() -> [OfferRow] { + + var offers = Set() + var results = Array() + + if let offer { + let offerStr = offer.encode() + offers.insert(offerStr) + results.append(OfferRow(offer: offerStr, isCurrentOffer: true)) + } + if let contact { + for offer in contact.offers { + let offerStr = offer.encode() + if !offers.contains(offerStr) { + offers.insert(offerStr) + results.append(OfferRow(offer: offerStr, isCurrentOffer: false)) + } + } + } + + return results + } + + func hasChanges() -> Bool { + + if let contact { + if name != contact.name { + return true + } + if pickerResult != nil { + return true + } + if doNotUseDiskImage { + return true + } + if trustedContact != contact.useOfferKey { + return true + } + + return false + + } else { + return true + } + } + + func canSave() -> Bool { + + if !hasName { + return false + } + if contact == nil { + if offer == nil && parsedOffer == nil { + return false + } + } + + return true + } + + // -------------------------------------------------- + // MARK: Notifications + // -------------------------------------------------- + + func onAppear() { + log.trace("onAppear()") + + switch location { + case .smartModal: + smartModalState.onNextDidAppear { + log.trace("didAppear()") + didAppear = true + } + + case .sheet: + didAppear = true + + case .embedded: + didAppear = true + } + } + + // -------------------------------------------------- + // MARK: Actions + // -------------------------------------------------- + + func selectImageOptionSelected() { + log.trace("selectImageOptionSelected()") + + activeSheet = .imagePicker + } + + func takePhotoOptionSelected() { + log.trace("takePhotoOptionSelected()") + + #if targetEnvironment(simulator) + toast.pop( + "Camera not supported on simulator", + colorScheme: colorScheme.opposite, + alignment: .none + ) + #else + activeSheet = .camera + #endif + } + + func copyText(_ text: String) { + log.trace("copyText()") + + UIPasteboard.general.string = text + toast.pop( + NSLocalizedString("Copied to pasteboard!", comment: "Toast message"), + colorScheme: colorScheme.opposite, + style: .chrome + ) + } + + func pastedOfferChanged() { + log.trace("pastedOfferChanged()") + + let text = pastedOffer.trimmingCharacters(in: .whitespacesAndNewlines) + if text.isEmpty { + pastedOfferIsInvalid = true + } else { + let result: Bitcoin_kmpTry = + Lightning_kmpOfferTypesOffer.companion.decode(s: text) + + if result.isFailure { + pastedOfferIsInvalid = true + } else { + pastedOfferIsInvalid = false + parsedOffer = result.get() + } + } + } + + func cancelButtonTapped() { + log.trace("cancelButtonTapped") + + close() + } + + func saveButtonTapped() { + log.trace("saveButtonTapped()") + + if hasChanges() && canSave() { + saveContact() + } else { + close() + } + } + + func discardChanges() { + log.trace("discardChages()") + + name = contact?.name ?? "" + pickerResult = nil + doNotUseDiskImage = false + trustedContact = contact?.useOfferKey ?? DEFAULT_TRUSTED + } + + func saveContact() { + log.trace("saveContact()") + + isSaving = true + Task { @MainActor in + + var updatedContact: ContactInfo? = nil + var success = false + do { + let updatedContactName = trimmedName + let updatedUseOfferKey = trustedContact + + var oldPhotoName: String? = contact?.photoUri + var newPhotoName: String? = nil + + if let pickerResult { + newPhotoName = try await PhotosManager.shared.writeToDisk(pickerResult) + } else if !doNotUseDiskImage { + newPhotoName = oldPhotoName + } + + log.debug("oldPhotoName: \(oldPhotoName ?? "")") + log.debug("newPhotoName: \(newPhotoName ?? "")") + + let contactsManager = Biz.business.contactsManager + + // There are 3 ways the ManageContact view is initialized: + // + // 1. With a non-nil contact, and possibly a non-nil offer. + // In this case we're updating the contact. + // The given offer may be highlighted in the UI. + // + // 2. With a nil contact, and a non-nil offer. + // In this case the user wishes to create a new contact + // associated with the given offer. + // + // 3. With a nil contact, and a nil offer. + // In this case, the user must paste a Bolt12 offer. + // And we'll create the new contact with the pasted offer. + + if let existingContact = contact { + updatedContact = try await contactsManager.updateContact( + contactId: existingContact.uuid, + name: updatedContactName, + photoUri: newPhotoName, + useOfferKey: updatedUseOfferKey, + offers: existingContact.offers + ) + } else if let newOffer = offer ?? parsedOffer { + let existingContact = try await contactsManager.getContactForOffer(offer: newOffer) + if let existingContact { + // The newOffer is actually NOT new. + // It already exists in the database and is attached to a contact. + // For now, we will update the details of that contact. + // In the future, it would be better to display some kind of error message, + // and then update the UI with this existing contact. + updatedContact = try await contactsManager.updateContact( + contactId: existingContact.uuid, + name: updatedContactName, + photoUri: newPhotoName, + useOfferKey: updatedUseOfferKey, + offers: existingContact.offers + ) + oldPhotoName = existingContact.photoUri + } else { + updatedContact = try await contactsManager.saveNewContact( + name: updatedContactName, + photoUri: newPhotoName, + useOfferKey: updatedUseOfferKey, + offer: newOffer + ) + } + } + + if let oldPhotoName, oldPhotoName != newPhotoName { + log.debug("Deleting old photo from disk...") + try await PhotosManager.shared.deleteFromDisk(fileName: oldPhotoName) + } + + success = true + } catch { + log.error("contactsManager: error: \(error)") + } + + isSaving = false + if success { + close() + } + if let updatedContact { + contactUpdated(updatedContact) + } + + } // + } + + func deleteContact() { + log.trace("deleteContact()") + + guard let cid = contact?.uuid else { + return + } + + isSaving = true + Task { @MainActor in + + let contactsManager = Biz.business.contactsManager + do { + try await contactsManager.deleteContact(contactId: cid) + + } catch { + log.error("contactsManager: error: \(error)") + } + + isSaving = false + close() + contactUpdated(nil) + + } // + } + + func close() { + log.trace("close()") + + switch location { + case .smartModal: + smartModalState.close() + case .sheet: + presentationMode.wrappedValue.dismiss() + case .embedded: + presentationMode.wrappedValue.dismiss() + } + } +} diff --git a/phoenix-ios/phoenix-ios/views/contacts/ManageContactSheet.swift b/phoenix-ios/phoenix-ios/views/contacts/ManageContactSheet.swift deleted file mode 100644 index 3ca2e2ed1..000000000 --- a/phoenix-ios/phoenix-ios/views/contacts/ManageContactSheet.swift +++ /dev/null @@ -1,395 +0,0 @@ -import SwiftUI -import PhoenixShared - -fileprivate let filename = "ManageContactSheet" -#if DEBUG && true -fileprivate var log = LoggerFactory.shared.logger(filename, .trace) -#else -fileprivate var log = LoggerFactory.shared.logger(filename, .warning) -#endif - -struct ManageContactSheet: View { - - let offer: Lightning_kmpOfferTypesOffer - @Binding var contact: ContactInfo? - let isNewContact: Bool - - @StateObject var toast = Toast() - - @State var name: String - @State var image: UIImage? - - @State var showImageOptions: Bool = false - @State var isSaving: Bool = false - @State var showDeleteContactConfirmationDialog: Bool = false - - enum ActiveSheet { - case camera - case imagePicker - } - @State var activeSheet: ActiveSheet? = nil - - // For the footer buttons: [cancel, save] - enum MaxFooterButtonWidth: Preference {} - let maxFooterButtonWidthReader = GeometryPreferenceReader( - key: AppendValue.self, - value: { [$0.size.width] } - ) - @State var maxFooterButtonWidth: CGFloat? = nil - - @Environment(\.colorScheme) var colorScheme: ColorScheme - - @EnvironmentObject var smartModalState: SmartModalState - - init(offer: Lightning_kmpOfferTypesOffer, contact: Binding) { - self.offer = offer - self._contact = contact - self.isNewContact = (contact.wrappedValue == nil) - - if let existingContact = contact.wrappedValue { - self._name = State(initialValue: existingContact.name) - if let photoUri = existingContact.photoUri { - let uiimage = UIImage(contentsOfFile: photoUri) - self._image = State(initialValue: uiimage) - } else { - self._image = State(initialValue: nil) - } - } else { - self._name = State(initialValue: "") - self._image = State(initialValue: nil) - } - } - - // -------------------------------------------------- - // MARK: View Builders - // -------------------------------------------------- - - @ViewBuilder - var body: some View { - - ZStack(alignment: Alignment.center) { - VStack(alignment: HorizontalAlignment.center, spacing: 0) { - header() - content() - footer() - } - toast.view() - } // - .sheet(isPresented: activeSheetBinding()) { // SwiftUI only allows for 1 ".sheet" - switch activeSheet! { - case .camera: - CameraPicker(image: $image) - - case .imagePicker: - ImagePicker(image: $image) - - } // - } - } - - @ViewBuilder - func header() -> some View { - - HStack(alignment: VerticalAlignment.center, spacing: 0) { - Group { - if isNewContact { - Text("Add contact") - } else { - Text("Edit contact") - } - } - .font(.title3) - .accessibilityAddTraits(.isHeader) - - Spacer(minLength: 0) - - if !isNewContact { - Button { - showDeleteContactConfirmationDialog = true - } label: { - Image(systemName: "trash.fill") - .imageScale(.medium) - .font(.title2) - .foregroundColor(.appNegative) - } - .accessibilityLabel("Delete contact") - } - } - .padding(.horizontal) - .padding(.vertical, 8) - .background( - Color(UIColor.secondarySystemBackground) - .cornerRadius(15, corners: [.topLeft, .topRight]) - ) - .padding(.bottom, 4) - .confirmationDialog("Delete contact?", - isPresented: $showDeleteContactConfirmationDialog, - titleVisibility: Visibility.hidden - ) { - Button("Delete contact", role: ButtonRole.destructive) { - deleteContact() - } - } - } - - @ViewBuilder - func content() -> some View { - - VStack(alignment: HorizontalAlignment.center, spacing: 0) { - - content_image().padding(.bottom) - content_textField().padding(.bottom) - content_details() - } - .padding() - } - - @ViewBuilder - func content_image() -> some View { - - Group { - if let uiimage = image { - Image(uiImage: uiimage) - .resizable() - .aspectRatio(contentMode: .fill) // FILL ! - } else { - Image(systemName: "person.circle") - .resizable() - .aspectRatio(contentMode: .fit) - .foregroundColor(.gray) -// if #available(iOS 17, *) { -// Image("user_round_symbol") -// .resizable() -// .aspectRatio(contentMode: .fit) -// } else { -// Image("user_round") -// .resizable() -// .aspectRatio(contentMode: .fit) -// } - } - } - .frame(width: 150, height: 150) - .clipShape(Circle()) - .onTapGesture { - if !isSaving { - showImageOptions = true - } - } - .confirmationDialog("Contact Image", - isPresented: $showImageOptions, - titleVisibility: .automatic - ) { - Button { - selectImageOptionSelected() - activeSheet = .imagePicker - } label: { - Text("Select image") - } - Button { - takePhotoOptionSelected() - } label: { - Text("Take photo") - } - if image != nil { - Button("Clear image", role: ButtonRole.destructive) { - image = nil - } - } - } // - } - - @ViewBuilder - func content_textField() -> some View { - - HStack(alignment: VerticalAlignment.center, spacing: 0) { - TextField("Name", text: $name) - .disabled(isSaving) - - // Clear button (appears when TextField's text is non-empty) - Button { - name = "" - } label: { - Image(systemName: "multiply.circle.fill") - .foregroundColor(Color(UIColor.tertiaryLabel)) - } - .disabled(isSaving) - .isHidden(name == "") - } - .padding(.all, 8) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.textFieldBorder, lineWidth: 1) - ) - } - - @ViewBuilder - func content_details() -> some View { - - VStack(alignment: HorizontalAlignment.leading, spacing: 0) { - Text("Offer:") - Text(offer.encode()) - .lineLimit(2) - .multilineTextAlignment(.leading) - .truncationMode(.middle) - .font(.subheadline) - .padding(.leading, 20) - } - .frame(maxWidth: .infinity) - } - - @ViewBuilder - func footer() -> some View { - - HStack(alignment: VerticalAlignment.center, spacing: 0) { - - Button { - cancel() - } label: { - HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 2) { - Image(systemName: "xmark") - Text("Cancel") - } - .frame(width: maxFooterButtonWidth) - .read(maxFooterButtonWidthReader) - } - .buttonStyle(.bordered) - .buttonBorderShape(.capsule) - .foregroundColor(hasName ? Color.appNegative : Color.appNegative.opacity(0.6)) - .disabled(isSaving) - - Spacer().frame(maxWidth: 16) - - Button { - saveContact() - } label: { - HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 2) { - Image(systemName: "checkmark") - Text("Save") - } - .frame(width: maxFooterButtonWidth) - .read(maxFooterButtonWidthReader) - } - .buttonStyle(.bordered) - .buttonBorderShape(.capsule) - .foregroundColor(hasName ? Color.appPositive : Color.appPositive.opacity(0.6)) - .disabled(isSaving || !hasName) - - } // - .padding(.vertical) - .assignMaxPreference(for: maxFooterButtonWidthReader.key, to: $maxFooterButtonWidth) - } - - // -------------------------------------------------- - // MARK: View Helpers - // -------------------------------------------------- - - func activeSheetBinding() -> Binding { - - return Binding( - get: { activeSheet != nil }, - set: { if !$0 { activeSheet = nil }} - ) - } - - var trimmedName: String { - return name.trimmingCharacters(in: .whitespacesAndNewlines) - } - - var hasName: Bool { - return !trimmedName.isEmpty - } - - // -------------------------------------------------- - // MARK: Actions - // -------------------------------------------------- - - func selectImageOptionSelected() { - log.trace("selectImageOptionSelected()") - - activeSheet = .imagePicker - } - - func takePhotoOptionSelected() { - log.trace("takePhotoOptionSelected()") - - #if targetEnvironment(simulator) - toast.pop( - "Camera not supported on simulator", - colorScheme: colorScheme.opposite, - alignment: .none - ) - #else - activeSheet = .camera - #endif - } - - func cancel() { - log.trace("cancel") - smartModalState.close() - } - - func saveContact() { - log.trace("saveContact()") - - isSaving = true - Task { @MainActor in - - let c_name = trimmedName - let _ = image?.jpegData(compressionQuality: 1.0) - // Todo: save to disk - - let contactsManager = Biz.business.contactsManager - do { - let existingContact = try await contactsManager.getContactForOffer(offer: offer) - if let existingContact { - contact = try await contactsManager.updateContact( - contactId: existingContact.uuid, - name: c_name, - photoUri: nil, - offers: existingContact.offers - ) - - } else { - contact = try await contactsManager.saveNewContact( - name: c_name, - photoUri: nil, - offer: offer - ) - } - } catch { - log.error("contactsManager: error: \(error)") - } - - isSaving = false - if contact != nil { - smartModalState.close() - } - - } // - } - - func deleteContact() { - log.trace("deleteContact()") - - guard let cid = contact?.uuid else { - return - } - - isSaving = true - Task { @MainActor in - - let contactsManager = Biz.business.contactsManager - do { - try await contactsManager.deleteContact(contactId: cid) - contact = nil - - } catch { - log.error("contactsManager: error: \(error)") - } - - isSaving = false - smartModalState.close() - - } // - } -} diff --git a/phoenix-ios/phoenix-ios/views/inspect/EditInfoView.swift b/phoenix-ios/phoenix-ios/views/inspect/EditInfoView.swift index 784db7acf..ae496d180 100644 --- a/phoenix-ios/phoenix-ios/views/inspect/EditInfoView.swift +++ b/phoenix-ios/phoenix-ios/views/inspect/EditInfoView.swift @@ -37,8 +37,8 @@ struct EditInfoView: View { let pi = paymentInfo.wrappedValue - defaultDescText = pi.paymentDescription(includingUserDescription: false) ?? "" - let realizedDesc = pi.paymentDescription(includingUserDescription: true) ?? "" + defaultDescText = pi.paymentDescription(options: .none) ?? "" + let realizedDesc = pi.paymentDescription(options: .userDescription) ?? "" if realizedDesc == defaultDescText { originalDescText = nil diff --git a/phoenix-ios/phoenix-ios/views/inspect/SummaryInfoGrid.swift b/phoenix-ios/phoenix-ios/views/inspect/SummaryInfoGrid.swift index 7add37314..cc03f380f 100644 --- a/phoenix-ios/phoenix-ios/views/inspect/SummaryInfoGrid.swift +++ b/phoenix-ios/phoenix-ios/views/inspect/SummaryInfoGrid.swift @@ -13,6 +13,8 @@ struct SummaryInfoGrid: InfoGridView { // See InfoGridView for architecture disc @Binding var paymentInfo: WalletPaymentInfo @Binding var showOriginalFiatValue: Bool + let showContactView: (_ contact: ContactInfo) -> Void + // let minKeyColumnWidth: CGFloat = 50 let maxKeyColumnWidth: CGFloat = 200 @@ -60,6 +62,8 @@ struct SummaryInfoGrid: InfoGridView { // See InfoGridView for architecture disc paymentMessageRow() customNotesRow() attachedMessageRow() + sentByRow() + recipientRow() paymentTypeRow() channelClosingRow() @@ -126,7 +130,9 @@ struct SummaryInfoGrid: InfoGridView { // See InfoGridView for architecture disc } valueColumn: { - let description = paymentInfo.paymentDescription() ?? paymentInfo.defaultPaymentDescription() + let description = + paymentInfo.paymentDescription(options: [.userDescription]) ?? + paymentInfo.defaultPaymentDescription() Text(description) .contextMenu { Button(action: { @@ -284,7 +290,6 @@ struct SummaryInfoGrid: InfoGridView { // See InfoGridView for architecture disc @ViewBuilder func attachedMessageRow() -> some View { - let identifier: String = #function if let msg = paymentInfo.attachedMessage() { @@ -301,16 +306,81 @@ struct SummaryInfoGrid: InfoGridView { // See InfoGridView for architecture disc } valueColumn: { - VStack(alignment: HorizontalAlignment.leading, spacing: 4) { - Text(msg) - if paymentInfo.payment.isIncoming() { - Text("Be careful with messages from unknown sources") - .foregroundColor(.secondary) - .font(.subheadline) + Text(msg) + + } // + } + } + + @ViewBuilder + func sentByRow() -> some View { + let identifier: String = #function + + if paymentInfo.payment.isIncoming() { + + InfoGridRow( + identifier: identifier, + vAlignment: .firstTextBaseline, + hSpacing: horizontalSpacingBetweenColumns, + keyColumnWidth: keyColumnWidth(identifier: identifier), + keyColumnAlignment: .trailing + ) { + + keyColumn("Sent By") + + } valueColumn: { + + if let contact = paymentInfo.contact { + + HStack(alignment: VerticalAlignment.center, spacing: 4) { + ContactPhoto(fileName: contact.photoUri, size: 32) + Text(contact.name) + } // + .onTapGesture { + showContactView(contact) + } + + } else { + + VStack(alignment: HorizontalAlignment.leading, spacing: 4) { + Text("Unknown") + if paymentInfo.attachedMessage() != nil { + Text("Be careful with messages from unknown sources") + .foregroundColor(.secondary) + .font(.subheadline) + } } } + } // + } + } + + @ViewBuilder + func recipientRow() -> some View { + let identifier: String = #function + + if paymentInfo.payment.isOutgoing(), let contact = paymentInfo.contact { + + InfoGridRow( + identifier: identifier, + vAlignment: .firstTextBaseline, + hSpacing: horizontalSpacingBetweenColumns, + keyColumnWidth: keyColumnWidth(identifier: identifier), + keyColumnAlignment: .trailing + ) { + + keyColumn("Sent To") + + } valueColumn: { + HStack(alignment: VerticalAlignment.center, spacing: 4) { + ContactPhoto(fileName: contact.photoUri, size: 32) + Text(contact.name) + } // + .onTapGesture { + showContactView(contact) + } } // } @@ -560,6 +630,10 @@ struct SummaryInfoGrid: InfoGridView { // See InfoGridView for architecture disc } } + // -------------------------------------------------- + // MARK: Utilities + // -------------------------------------------------- + func formattedAmount(msat: Int64) -> FormattedAmount { if showOriginalFiatValue && currencyPrefs.currencyType == .fiat { @@ -576,6 +650,7 @@ struct SummaryInfoGrid: InfoGridView { // See InfoGridView for architecture disc } } + // Todo: Perform decryption in a background thread, and store in a State variable. func decrypt(aes sa_aes: LnurlPay.Invoice_SuccessAction_Aes) -> LnurlPay.Invoice_SuccessAction_Aes_Decrypted? { guard diff --git a/phoenix-ios/phoenix-ios/views/inspect/SummaryView.swift b/phoenix-ios/phoenix-ios/views/inspect/SummaryView.swift index daa7bd239..ec97ac9e3 100644 --- a/phoenix-ios/phoenix-ios/views/inspect/SummaryView.swift +++ b/phoenix-ios/phoenix-ios/views/inspect/SummaryView.swift @@ -38,6 +38,14 @@ struct SummaryView: View { @EnvironmentObject var currencyPrefs: CurrencyPrefs @EnvironmentObject var smartModalState: SmartModalState + enum NavLinkTag { + case DetailsView + case EditInfoView + case CpfpView(onChainPayment: Lightning_kmpOnChainOutgoingPayment) + case ContactView(contact: ContactInfo) + } + @State var navLinkTag: NavLinkTag? = nil + enum ButtonWidth: Preference {} let buttonWidthReader = GeometryPreferenceReader( key: AppendValue.self, @@ -109,6 +117,16 @@ struct SummaryView: View { ZStack { + if #unavailable(iOS 16.0) { + NavigationLink( + destination: navLinkView(), + isActive: navLinkTagBinding() + ) { + EmptyView() + } + .isDetailLink(false) + } // else: uses.navigationStackDestination() + // This technique is used to center the content vertically GeometryReader { geometry in ScrollView(.vertical) { @@ -137,6 +155,9 @@ struct SummaryView: View { } } // + .navigationStackDestination(isPresented: navLinkTagBinding()) { // For iOS 16+ + navLinkView() + } .onAppear { onAppear() } @@ -152,7 +173,7 @@ struct SummaryView: View { Spacer(minLength: 25) header_status() header_amount() - SummaryInfoGrid(paymentInfo: $paymentInfo, showOriginalFiatValue: $showOriginalFiatValue) + summaryInfoGrid() buttonList() Spacer(minLength: 25) } @@ -330,7 +351,9 @@ struct SummaryView: View { } // if confirmations == 0 && supportsBumpFee(onChainPayment) { - NavigationLink(destination: cpfpView(onChainPayment)) { + Button { + navLinkTag = .CpfpView(onChainPayment: onChainPayment) + } label: { Label { Text("Accelerate transaction") } icon: { @@ -473,6 +496,16 @@ struct SummaryView: View { .accessibilityLabel("\(isOutgoing ? "-" : "+")\(amount.string)") } + @ViewBuilder + func summaryInfoGrid() -> some View { + + SummaryInfoGrid( + paymentInfo: $paymentInfo, + showOriginalFiatValue: $showOriginalFiatValue, + showContactView: showContactView + ) + } + @ViewBuilder func buttonList() -> some View { @@ -510,7 +543,9 @@ struct SummaryView: View { HStack(alignment: VerticalAlignment.center, spacing: 16) { TruncatableView(fixedHorizontal: true, fixedVertical: true) { - NavigationLink(destination: detailsView()) { + Button { + navLinkTag = .DetailsView + } label: { buttonLabel_details() .lineLimit(1) } @@ -527,7 +562,9 @@ struct SummaryView: View { } TruncatableView(fixedHorizontal: true, fixedVertical: true) { - NavigationLink(destination: editInfoView()) { + Button { + navLinkTag = .EditInfoView + } label: { buttonLabel_edit() } .frame(minWidth: buttonWidth, alignment: Alignment.trailing) @@ -576,7 +613,9 @@ struct SummaryView: View { HStack(alignment: VerticalAlignment.center, spacing: 16) { TruncatableView(fixedHorizontal: true, fixedVertical: true) { - NavigationLink(destination: detailsView()) { + Button { + navLinkTag = .DetailsView + } label: { buttonLabel_details() .lineLimit(1) } @@ -593,7 +632,9 @@ struct SummaryView: View { } TruncatableView(fixedHorizontal: true, fixedVertical: true) { - NavigationLink(destination: editInfoView()) { + Button { + navLinkTag = .EditInfoView + } label: { buttonLabel_edit() .lineLimit(1) } @@ -644,7 +685,9 @@ struct SummaryView: View { HStack(alignment: VerticalAlignment.center, spacing: 8) { TruncatableView(fixedHorizontal: true, fixedVertical: true) { - NavigationLink(destination: detailsView()) { + Button { + navLinkTag = .DetailsView + } label: { buttonLabel_details() .lineLimit(1) } @@ -659,7 +702,9 @@ struct SummaryView: View { } TruncatableView(fixedHorizontal: true, fixedVertical: true) { - NavigationLink(destination: editInfoView()) { + Button { + navLinkTag = .EditInfoView + } label: { buttonLabel_edit() .lineLimit(1) } @@ -708,7 +753,9 @@ struct SummaryView: View { HStack(alignment: VerticalAlignment.center, spacing: 8) { - NavigationLink(destination: detailsView()) { + Button { + navLinkTag = .DetailsView + } label: { buttonLabel_details() .read(buttonHeightReader) } @@ -717,7 +764,9 @@ struct SummaryView: View { Divider().frame(height: buttonHeight) } - NavigationLink(destination: editInfoView()) { + Button { + navLinkTag = .EditInfoView + } label: { buttonLabel_edit() .read(buttonHeightReader) } @@ -766,35 +815,69 @@ struct SummaryView: View { } @ViewBuilder - func detailsView() -> some View { - DetailsView( + func cpfpView(_ onChainPayment: Lightning_kmpOnChainOutgoingPayment) -> some View { + CpfpView( location: wrappedLocation(), - paymentInfo: $paymentInfo, - showOriginalFiatValue: $showOriginalFiatValue, - showFiatValueExplanation: $showFiatValueExplanation + onChainPayment: onChainPayment ) } @ViewBuilder - func editInfoView() -> some View { - EditInfoView( - location: wrappedLocation(), - paymentInfo: $paymentInfo - ) + func navLinkView() -> some View { + + if let tag = self.navLinkTag { + navLinkView(tag) + } else { + EmptyView() + } } @ViewBuilder - func cpfpView(_ onChainPayment: Lightning_kmpOnChainOutgoingPayment) -> some View { - CpfpView( - location: wrappedLocation(), - onChainPayment: onChainPayment - ) + private func navLinkView(_ tag: NavLinkTag) -> some View { + + switch tag { + case .DetailsView: + DetailsView( + location: wrappedLocation(), + paymentInfo: $paymentInfo, + showOriginalFiatValue: $showOriginalFiatValue, + showFiatValueExplanation: $showFiatValueExplanation + ) + + case .EditInfoView: + EditInfoView( + location: wrappedLocation(), + paymentInfo: $paymentInfo + ) + + case .CpfpView(let onChainPayment): + CpfpView( + location: wrappedLocation(), + onChainPayment: onChainPayment + ) + + case .ContactView(let contact): + ManageContact( + location: manageContactLocation(), + offer: nil, + contact: contact, + contactUpdated: { _ in } + ) + } } // -------------------------------------------------- // MARK: View Helpers // -------------------------------------------------- + func navLinkTagBinding() -> Binding { + + return Binding( + get: { navLinkTag != nil }, + set: { if !$0 { navLinkTag = nil }} + ) + } + func formattedAmount() -> FormattedAmount { let msat = paymentInfo.payment.amount @@ -834,6 +917,16 @@ struct SummaryView: View { } } + func manageContactLocation() -> ManageContact.Location { + + switch location { + case .sheet(_): + return ManageContact.Location.sheet + case .embedded(_): + return ManageContact.Location.embedded + } + } + func supportsBumpFee(_ onChainPayment: Lightning_kmpOnChainOutgoingPayment) -> Bool { switch onChainPayment { @@ -1000,6 +1093,12 @@ struct SummaryView: View { } } + func showContactView(_ contact: ContactInfo) { + log.trace("showContactView()") + + navLinkTag = .ContactView(contact: contact) + } + func exploreTx(_ txId: Bitcoin_kmpTxId, website: BlockchainExplorer.Website) { log.trace("exploreTX()") diff --git a/phoenix-ios/phoenix-ios/views/layers/Popover.swift b/phoenix-ios/phoenix-ios/views/layers/Popover.swift index bbd84726d..1e9167e1b 100644 --- a/phoenix-ios/phoenix-ios/views/layers/Popover.swift +++ b/phoenix-ios/phoenix-ios/views/layers/Popover.swift @@ -24,16 +24,21 @@ public class PopoverState: ObservableObject { /// - view will animate on screen (onWillAppear) /// - view has animated off screen (onDidDisappear) /// - var itemPublisher = CurrentValueSubject(nil) + let itemPublisher = CurrentValueSubject(nil) + + /// Fires when: + /// - view has animated on screen (onDidAppear) + /// + let didAppearPublisher = PassthroughSubject() /// Fires when: /// - view will animate off screen (onWillDisapper) /// - var closePublisher = PassthroughSubject() + let willDisappearPublisher = PassthroughSubject() /// Whether or not the popover is dismissable by tapping outside the popover. /// - var dismissablePublisher = CurrentValueSubject(true) + let dismissablePublisher = CurrentValueSubject(true) var currentItem: PopoverItem? { return itemPublisher.value @@ -47,42 +52,49 @@ public class PopoverState: ObservableObject { func display( dismissable: Bool, @ViewBuilder builder: () -> Content, - onWillDisappear: (() -> Void)? = nil + onDidAppear: (() -> Void)? = nil, + onWillDisappear: (() -> Void)? = nil, + onDidDisappear: (() -> Void)? = nil ) { dismissablePublisher.send(dismissable) itemPublisher.send(PopoverItem( view: builder().anyView )) + if let didAppearLambda = onDidAppear { + onNextDidAppear(didAppearLambda) + } if let willDisappearLambda = onWillDisappear { onNextWillDisappear(willDisappearLambda) } + if let didDisappearLambda = onDidDisappear { + onNextDidDisappear(didDisappearLambda) + } } func close() { - closePublisher.send() + willDisappearPublisher.send() } func close(animationCompletion: @escaping () -> Void) { + onNextDidDisappear(animationCompletion) + willDisappearPublisher.send() + } + + func onNextDidAppear(_ action: @escaping () -> Void) { var cancellables = Set() - itemPublisher.sink { (item: PopoverItem?) in + didAppearPublisher.sink { _ in - // NB: This fires right away because itemPublisher is CurrentValueSubject. - // It only means `onDidDisappear` if item is nil. - if item == nil { - animationCompletion() - cancellables.removeAll() - } + action() + cancellables.removeAll() }.store(in: &cancellables) - - closePublisher.send() } func onNextWillDisappear(_ action: @escaping () -> Void) { var cancellables = Set() - closePublisher.sink { _ in + willDisappearPublisher.sink { _ in action() cancellables.removeAll() @@ -177,7 +189,7 @@ struct PopoverWrapper: View { animation = 1 } } - .onReceive(popoverState.closePublisher) { _ in + .onReceive(popoverState.willDisappearPublisher) { _ in withAnimation { animation = 2 } @@ -194,6 +206,7 @@ struct PopoverWrapper: View { if animation == 1 { // Popover is now visible UIAccessibility.post(notification: .screenChanged, argument: nil) + popoverState.didAppearPublisher.send() } else if animation == 2 { // Popover is now hidden UIAccessibility.post(notification: .screenChanged, argument: nil) diff --git a/phoenix-ios/phoenix-ios/views/layers/ShortSheet.swift b/phoenix-ios/phoenix-ios/views/layers/ShortSheet.swift index c86000bb1..f763eef56 100644 --- a/phoenix-ios/phoenix-ios/views/layers/ShortSheet.swift +++ b/phoenix-ios/phoenix-ios/views/layers/ShortSheet.swift @@ -24,16 +24,21 @@ public class ShortSheetState: ObservableObject { /// - sheet view will animate on screen (onWillAppear) /// - sheet view has animated off screen (onDidDisappear) /// - var itemPublisher = CurrentValueSubject(nil) + let itemPublisher = CurrentValueSubject(nil) + + /// Fires when: + /// - view has animated on screen (onDidAppear) + /// + let didAppearPublisher = PassthroughSubject() /// Fires when: /// - sheet view will animate off screen (onWillDisapper) /// - var closePublisher = PassthroughSubject() + let willDisappearPublisher = PassthroughSubject() /// Whether or not the sheet is dimissable by tapping outside the sheet. /// - var dismissablePublisher = CurrentValueSubject(true) + let dismissablePublisher = CurrentValueSubject(true) var currentItem: ShortSheetItem? { return itemPublisher.value @@ -47,42 +52,49 @@ public class ShortSheetState: ObservableObject { func display( dismissable: Bool, @ViewBuilder builder: () -> Content, - onWillDisappear: (() -> Void)? = nil + onDidAppear: (() -> Void)? = nil, + onWillDisappear: (() -> Void)? = nil, + onDidDisappear: (() -> Void)? = nil ) { dismissablePublisher.send(dismissable) itemPublisher.send(ShortSheetItem( view: builder().anyView )) + if let didAppearLambda = onDidAppear { + onNextDidAppear(didAppearLambda) + } if let willDisappearLambda = onWillDisappear { onNextWillDisappear(willDisappearLambda) } + if let didDisappearLambda = onDidDisappear { + onNextDidDisappear(didDisappearLambda) + } } func close() { - closePublisher.send() + willDisappearPublisher.send() } func close(animationCompletion: @escaping () -> Void) { + onNextDidDisappear(animationCompletion) + willDisappearPublisher.send() + } + + func onNextDidAppear(_ action: @escaping () -> Void) { var cancellables = Set() - itemPublisher.sink { (item: ShortSheetItem?) in + didAppearPublisher.sink { _ in - // NB: This fires right away because itemPublisher is CurrentValueSubject. - // It only means `onDidDisappear` if item is nil. - if item == nil { - animationCompletion() - cancellables.removeAll() - } + action() + cancellables.removeAll() }.store(in: &cancellables) - - closePublisher.send() } func onNextWillDisappear(_ action: @escaping () -> Void) { var cancellables = Set() - closePublisher.sink { _ in + willDisappearPublisher.sink { _ in action() cancellables.removeAll() @@ -169,7 +181,7 @@ struct ShortSheetWrapper: View { animation = 1 } } - .onReceive(shortSheetState.closePublisher) { _ in + .onReceive(shortSheetState.willDisappearPublisher) { _ in withAnimation { animation = 2 } @@ -186,6 +198,7 @@ struct ShortSheetWrapper: View { if animation == 1 { // ShortSheet is now visible UIAccessibility.post(notification: .screenChanged, argument: nil) + shortSheetState.didAppearPublisher.send() } else if animation == 2 { // ShortSheet is now hidden diff --git a/phoenix-ios/phoenix-ios/views/layers/SmartModal.swift b/phoenix-ios/phoenix-ios/views/layers/SmartModal.swift index eaebd2c19..8cdf9f78f 100644 --- a/phoenix-ios/phoenix-ios/views/layers/SmartModal.swift +++ b/phoenix-ios/phoenix-ios/views/layers/SmartModal.swift @@ -63,19 +63,25 @@ public class SmartModalState: ObservableObject { func display( dismissable: Bool, @ViewBuilder builder: () -> Content, - onWillDisappear: (() -> Void)? = nil + onDidAppear: (() -> Void)? = nil, + onWillDisappear: (() -> Void)? = nil, + onDidDisappear: (() -> Void)? = nil ) { if isIPad { popoverState.display( dismissable: dismissable, builder: builder, - onWillDisappear: onWillDisappear + onDidAppear: onDidAppear, + onWillDisappear: onWillDisappear, + onDidDisappear: onDidDisappear ) } else { shortSheetState.display( dismissable: dismissable, builder: builder, - onWillDisappear: onWillDisappear + onDidAppear: onDidAppear, + onWillDisappear: onWillDisappear, + onDidDisappear: onDidDisappear ) } } @@ -96,6 +102,14 @@ public class SmartModalState: ObservableObject { } } + func onNextDidAppear(_ action: @escaping () -> Void) { + if isIPad { + popoverState.onNextDidAppear(action) + } else { + shortSheetState.onNextDidAppear(action) + } + } + func onNextWillDisappear(_ action: @escaping () -> Void) { if isIPad { popoverState.onNextWillDisappear(action) diff --git a/phoenix-ios/phoenix-ios/views/main/HomeView.swift b/phoenix-ios/phoenix-ios/views/main/HomeView.swift index 283da206f..8ba109beb 100644 --- a/phoenix-ios/phoenix-ios/views/main/HomeView.swift +++ b/phoenix-ios/phoenix-ios/views/main/HomeView.swift @@ -53,6 +53,8 @@ struct HomeView : MVIView { @State var bizNotifications_payment: [PhoenixShared.NotificationsManager.NotificationItem] = [] @State var bizNotifications_watchtower: [PhoenixShared.NotificationsManager.NotificationItem] = [] + let contactsPublisher = Biz.business.contactsManager.contactsListPublisher() + @State var didAppear = false enum NoticeBoxContentHeight: Preference {} @@ -133,6 +135,9 @@ struct HomeView : MVIView { .onReceive(bizNotificationsPublisher) { bizNotificationsChanged($0) } + .onReceive(contactsPublisher) { + contactsChanged($0) + } } @ViewBuilder @@ -799,7 +804,7 @@ struct HomeView : MVIView { lastCompletedPaymentId = paymentId } - // PaymentView will need `WalletPaymentFetchOptions.companion.All`, + // SummaryView will need `WalletPaymentFetchOptions.companion.All`, // so as long as we're fetching from the database, we might as well fetch everything we need. let options = WalletPaymentFetchOptions.companion.All @@ -860,6 +865,12 @@ struct HomeView : MVIView { }) } + func contactsChanged(_ contacts: [ContactInfo]) { + log.trace("contactsChanged()") + + paymentsPage = paymentsPage.forceRefresh() + } + fileprivate func footerCellDidAppear() { log.trace("footerCellDidAppear()") @@ -885,7 +896,9 @@ struct HomeView : MVIView { if maybeHasMoreRowsInDatabase { log.debug("maybeHasMoreRowsInDatabase") - if case let .withinTime(recentPaymentSeconds) = recentPaymentsConfig { + switch recentPaymentsConfig { + case .withinTime(let recentPaymentSeconds): + log.debug("recentPaymentsConfig.withinTime(seconds: \(recentPaymentSeconds))") // increase paymentsPage.count @@ -899,8 +912,27 @@ struct HomeView : MVIView { count: newCount, seconds: Int32(recentPaymentSeconds) ) - } else { - log.debug("!recentPayments.withinTime(X)") + + case .mostRecent(let count): + log.debug("recentPaymentsConfig.mostRecent(count: \(count))") + + // Nothing to do here. + // The original subscription was configured with the correct count. + + case .inFlightOnly: + log.debug("recentPaymentsConfig.inFlightOnly") + + // increase paymentsPage.count + + let prvOffset = paymentsPage.offset + let newCount = paymentsPage.count + Int32(PAGE_COUNT_INCREMENT) + + log.debug("increasing page.count: Page(offset=\(prvOffset), count=\(newCount)") + + paymentsPageFetcher.subscribeToInFlight( + offset: prvOffset, + count: newCount + ) } } else { diff --git a/phoenix-ios/phoenix-ios/views/send/ManualInput.swift b/phoenix-ios/phoenix-ios/views/send/ManualInput.swift new file mode 100644 index 000000000..0e088fd76 --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/send/ManualInput.swift @@ -0,0 +1,97 @@ +import SwiftUI +import PhoenixShared + +fileprivate let filename = "ManualInput" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +struct ManualInput: View { + + @ObservedObject var mvi: MVIState + + @State var input = "" + + @EnvironmentObject var smartModalState: SmartModalState + + @ViewBuilder + var body: some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 0) { + + Text("Manual Input") + .font(.title2) + .padding(.bottom) + .accessibilityAddTraits(.isHeader) + + Text( + """ + Enter a Lightning invoice, LNURL, or Lightning address \ + you want to send money to. + """ + ) + .padding(.bottom) + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + TextField("", text: $input) + + // Clear button (appears when TextField's text is non-empty) + Button { + input = "" + } label: { + Image(systemName: "multiply.circle.fill") + .foregroundColor(.secondary) + } + .accessibilityLabel("Clear textfield") + .isHidden(input == "") + } + .padding(.all, 8) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color.textFieldBorder, lineWidth: 1) + ) + .padding(.bottom) + .padding(.bottom) + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Spacer() + + Button("Cancel") { + didCancel() + } + .font(.title3) + + Divider() + .frame(maxHeight: 20, alignment: Alignment.center) + .padding([.leading, .trailing]) + + Button("OK") { + didConfirm() + } + .font(.title3) + } + + } // + .padding() + } + + func didCancel() -> Void { + log.trace("didCancel()") + + smartModalState.close() + } + + func didConfirm() { + log.trace("didConfirm()") + + let request = input.trimmingCharacters(in: .whitespacesAndNewlines) + if request.count > 0 { + mvi.intent(Scan.Intent_Parse(request: request)) + } + + smartModalState.close() + } +} + diff --git a/phoenix-ios/phoenix-ios/views/send/PaymentDetails.swift b/phoenix-ios/phoenix-ios/views/send/PaymentDetails.swift index 5661afedb..4df09ca79 100644 --- a/phoenix-ios/phoenix-ios/views/send/PaymentDetails.swift +++ b/phoenix-ios/phoenix-ios/views/send/PaymentDetails.swift @@ -255,23 +255,7 @@ struct PaymentDetails: View { if CONTACTS_ENABLED, let contact = parent.contact { HStack(alignment: VerticalAlignment.center, spacing: 4) { - Group { - if let photoUri = contact.photoUri, - let uiImage = UIImage(contentsOfFile: photoUri) - { - Image(uiImage: uiImage) - .resizable() - .aspectRatio(contentMode: .fill) // FILL ! - } else { - Image(systemName: "person.circle") - .resizable() - .aspectRatio(contentMode: .fit) - .foregroundColor(.gray) - } - } - .frame(width: 32, height: 32) - .clipShape(Circle()) - + ContactPhoto(fileName: contact.photoUri, size: 32) Text(contact.name) } // .onTapGesture { diff --git a/phoenix-ios/phoenix-ios/views/send/ScanView.swift b/phoenix-ios/phoenix-ios/views/send/ScanView.swift index db2f6c98f..6aed02aef 100644 --- a/phoenix-ios/phoenix-ios/views/send/ScanView.swift +++ b/phoenix-ios/phoenix-ios/views/send/ScanView.swift @@ -21,7 +21,7 @@ struct ScanView: View { @State var clipboardContent: Scan.ClipboardContent? = nil @State var showingImagePicker = false - @State var imagePickerSelection: UIImage? = nil + @State var imagePickerResult: PickerResult? = nil @State var ignoreScanner: Bool = false @@ -117,7 +117,7 @@ struct ScanView: View { } .ignoresSafeArea(.keyboard) // disable keyboard avoidance on this view .sheet(isPresented: $showingImagePicker) { - ImagePicker(image: $imagePickerSelection) + ImagePicker(copyFile: false, result: $imagePickerResult) } } @@ -189,6 +189,13 @@ struct ScanView: View { Divider() + menuOption_contacts() + .padding(.horizontal, 20) + .padding(.vertical, 12) + .accessibilitySortPriority(2) + + Divider() + menuOption_chooseImage() .padding(.horizontal, 20) .padding(.vertical, 12) @@ -364,6 +371,21 @@ struct ScanView: View { } // } + @ViewBuilder + func menuOption_contacts() -> some View { + + Button { + showContactsList() + } label: { + Label { + Text("Contacts") + } icon: { + Image(systemName: "person.2") + } + } + .font(.title3) + } + @ViewBuilder func menuOption_chooseImage() -> some View { @@ -377,7 +399,7 @@ struct ScanView: View { } } .font(.title3) - .onChange(of: imagePickerSelection) { _ in + .onChange(of: imagePickerResult) { _ in imagePickerDidChooseImage() } } @@ -527,22 +549,42 @@ struct ScanView: View { mvi.intent(Scan.Intent_Parse(request: request)) } } + + func showContactsList() { + log.trace("showContactsList()") + + ignoreScanner = true + smartModalState.display(dismissable: true) { + ContactsListSheet(didSelectContact: didSelectContact) + } onDidDisappear: { + ignoreScanner = false + } + } + + func didSelectContact(_ contact: ContactInfo) { + log.trace("didSelectContact()") + + if let offer = contact.mostRelevantOffer { + mvi.intent(Scan.Intent_Parse(request: offer.encode())) + } + } func manualInput() { log.trace("manualInput()") ignoreScanner = true smartModalState.display(dismissable: true) { - - ManualInput(mvi: mvi, ignoreScanner: $ignoreScanner) + ManualInput(mvi: mvi) + } onDidDisappear: { + ignoreScanner = false } } func imagePickerDidChooseImage() { log.trace("imagePickerDidChooseImage()") - guard let uiImage = imagePickerSelection else { return } - imagePickerSelection = nil + guard let uiImage = imagePickerResult?.image else { return } + imagePickerResult = nil if let ciImage = CIImage(image: uiImage) { var options: [String: Any] @@ -581,97 +623,3 @@ struct ScanView: View { } } } - -// -------------------------------------------------- -// MARK: - -// -------------------------------------------------- - -struct ManualInput: View, ViewName { - - @ObservedObject var mvi: MVIState - @Binding var ignoreScanner: Bool - - @State var input = "" - - @EnvironmentObject var smartModalState: SmartModalState - - @ViewBuilder - var body: some View { - - VStack(alignment: HorizontalAlignment.leading, spacing: 0) { - - Text("Manual Input") - .font(.title2) - .padding(.bottom) - .accessibilityAddTraits(.isHeader) - - Text( - """ - Enter a Lightning invoice, LNURL, or Lightning address \ - you want to send money to. - """ - ) - .padding(.bottom) - - HStack(alignment: VerticalAlignment.center, spacing: 0) { - TextField("", text: $input) - - // Clear button (appears when TextField's text is non-empty) - Button { - input = "" - } label: { - Image(systemName: "multiply.circle.fill") - .foregroundColor(.secondary) - } - .accessibilityLabel("Clear textfield") - .isHidden(input == "") - } - .padding(.all, 8) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(Color.textFieldBorder, lineWidth: 1) - ) - .padding(.bottom) - .padding(.bottom) - - HStack(alignment: VerticalAlignment.center, spacing: 0) { - Spacer() - - Button("Cancel") { - didCancel() - } - .font(.title3) - - Divider() - .frame(maxHeight: 20, alignment: Alignment.center) - .padding([.leading, .trailing]) - - Button("OK") { - didConfirm() - } - .font(.title3) - } - - } // - .padding() - } - - func didCancel() -> Void { - log.trace("[\(viewName)] didCancel()") - - smartModalState.close { - ignoreScanner = false - } - } - - func didConfirm() -> Void { - log.trace("[\(viewName)] didConfirm()") - - let request = input.trimmingCharacters(in: .whitespacesAndNewlines) - if request.count > 0 { - mvi.intent(Scan.Intent_Parse(request: request)) - } - - smartModalState.close() - } -} diff --git a/phoenix-ios/phoenix-ios/views/send/SendView.swift b/phoenix-ios/phoenix-ios/views/send/SendView.swift index 97c32b998..4783d94c2 100644 --- a/phoenix-ios/phoenix-ios/views/send/SendView.swift +++ b/phoenix-ios/phoenix-ios/views/send/SendView.swift @@ -217,12 +217,19 @@ struct SendView: MVIView { comment: "Error message - scanning lightning invoice" ) - case is Scan.BadRequestReason_InvalidBip353: + case is Scan.BadRequestReason_Bip353InvalidOffer: msg = NSLocalizedString( - "Invalid BIP353 DNS address", + "This address uses an invalid Bolt12 offer.", comment: "Error message - dns record contains an invalid offer" ) + + case is Scan.BadRequestReason_Bip353NoDNSSEC: + + msg = NSLocalizedString( + "This address is hosted on an unsecure DNS. DNSSEC must be enabled.", + comment: "Error message - dns issue" + ) case let serviceError as Scan.BadRequestReason_ServiceError: diff --git a/phoenix-ios/phoenix-ios/views/send/ValidateView.swift b/phoenix-ios/phoenix-ios/views/send/ValidateView.swift index 978c7b6f4..bbb483557 100644 --- a/phoenix-ios/phoenix-ios/views/send/ValidateView.swift +++ b/phoenix-ios/phoenix-ios/views/send/ValidateView.swift @@ -1476,10 +1476,21 @@ struct ValidateView: View { dismissKeyboardIfVisible() smartModalState.display(dismissable: false) { - ManageContactSheet(offer: offer, contact: $contact) + ManageContact( + location: .smartModal, + offer: offer, + contact: contact, + contactUpdated: contactUpdated + ) } } + func contactUpdated(_ updatedContact: ContactInfo?) { + log.trace("contactUpdated()") + + contact = updatedContact + } + func maybeShowCapacityImpactWarning() { log.trace("maybeShowCapacityImpactWarning()") @@ -1589,11 +1600,11 @@ struct ValidateView: View { Biz.beginLongLivedTask(id: paymentId.description()) let payerKey: Bitcoin_kmpPrivateKey - if Prefs.shared.randomPayerKey { - payerKey = Lightning_randomKey() - } else { + if contact?.useOfferKey ?? false { let offerData = try await Biz.business.nodeParamsManager.defaultOffer() payerKey = offerData.payerKey + } else { + payerKey = Lightning_randomKey() } let result: Lightning_kmpSendPaymentResult = try await peer.altPayOffer( @@ -1652,11 +1663,11 @@ struct ValidateView: View { Biz.beginLongLivedTask(id: paymentId.description()) let payerKey: Bitcoin_kmpPrivateKey - if Prefs.shared.randomPayerKey { - payerKey = Lightning_randomKey() - } else { + if contact?.useOfferKey ?? false { let offerData = try await Biz.business.nodeParamsManager.defaultOffer() payerKey = offerData.payerKey + } else { + payerKey = Lightning_randomKey() } let response: Lightning_kmpOfferNotPaid? = try await peer.betterPayOffer( diff --git a/phoenix-ios/phoenix-ios/views/transactions/PaymentCell.swift b/phoenix-ios/phoenix-ios/views/transactions/PaymentCell.swift index 68fc80d66..3b849da8e 100644 --- a/phoenix-ios/phoenix-ios/views/transactions/PaymentCell.swift +++ b/phoenix-ios/phoenix-ios/views/transactions/PaymentCell.swift @@ -12,6 +12,8 @@ struct PaymentCell : View { static let fetchOptions = WalletPaymentFetchOptions.companion.Descriptions.plus( other: WalletPaymentFetchOptions.companion.OriginalFiat + ).plus( + other: WalletPaymentFetchOptions.companion.Contact ) private let paymentsManager = Biz.business.paymentsManager @@ -89,12 +91,12 @@ struct PaymentCell : View { paymentImage() VStack(alignment: HorizontalAlignment.leading) { - Text(paymentDescription()) + Text(line1()) .lineLimit(1) .truncationMode(.tail) .foregroundColor(.primaryForeground) - Text(paymentTimestamp()) + Text(line2()) .font(.caption) .lineLimit(1) .truncationMode(.tail) @@ -118,12 +120,12 @@ struct PaymentCell : View { VStack(alignment: HorizontalAlignment.leading, spacing: 0) { - Text(paymentDescription()) + Text(line1()) .lineLimit(1) .truncationMode(.tail) .foregroundColor(.primaryForeground) - Text(paymentTimestamp()) + Text(line2()) .font(.caption) .lineLimit(1) .truncationMode(.tail) @@ -206,7 +208,7 @@ struct PaymentCell : View { // MARK: View Helpers // -------------------------------------------------- - func paymentDescription() -> String { + func line1() -> String { if let fetched = fetched { return fetched.paymentDescription() ?? fetched.defaultPaymentDescription() @@ -215,7 +217,7 @@ struct PaymentCell : View { } } - func paymentTimestamp() -> String { + func line2() -> String { guard let payment = fetched?.payment else { return "" @@ -223,19 +225,60 @@ struct PaymentCell : View { guard let completedAtDate = payment.completedAtDate else { if payment.isOnChain() { - return NSLocalizedString("waiting for confirmations", comment: "explanation for pending transaction") + return String( + localized: "waiting for confirmations", + comment: "explanation for pending transaction" + ) } else { - return NSLocalizedString("pending", comment: "timestamp string for pending transaction") + return String( + localized: "pending", + comment: "timestamp string for pending transaction" + ) } } + let timestamp = stringForDate(completedAtDate) + + if let contact = fetched?.contact { + if let payment = fetched?.payment, payment.isIncoming() { + return String(localized: "\(timestamp) - from \(contact.name)") + } else { + return String(localized: "\(timestamp) - to \(contact.name)") + } + + } else { + return timestamp + } + } + + func stringForDate(_ completedAtDate: Date) -> String { + + let calendar = Calendar.current + let compsA = calendar.dateComponents([.year], from: completedAtDate) + let compsB = calendar.dateComponents([.year], from: Date.now) + + let yearA = compsA.year ?? 0 + let yearB = compsB.year ?? 0 + + let preferShortDate = (textScaling > 100) || (fetched?.contact != nil) + let formatter = DateFormatter() - if textScaling > 100 { - formatter.dateStyle = .short + if yearA == yearB { + + if preferShortDate { + formatter.setLocalizedDateFormatFromTemplate("MMMdjmma") // ≈ dateStyle.medium - year + } else { + formatter.setLocalizedDateFormatFromTemplate("MMMMdjmma") // ≈ dateStyle.long - year + } } else { - formatter.dateStyle = .long + + if preferShortDate { + formatter.dateStyle = .short + } else { + formatter.dateStyle = .long + } + formatter.timeStyle = .short } - formatter.timeStyle = .short return formatter.string(from: completedAtDate) } diff --git a/phoenix-ios/phoenix-ios/views/transactions/TransactionsView.swift b/phoenix-ios/phoenix-ios/views/transactions/TransactionsView.swift index 128ca171b..29c0afec7 100644 --- a/phoenix-ios/phoenix-ios/views/transactions/TransactionsView.swift +++ b/phoenix-ios/phoenix-ios/views/transactions/TransactionsView.swift @@ -286,30 +286,43 @@ struct TransactionsView: View { let dateFormatter = DateFormatter() dateFormatter.setLocalizedDateFormatFromTemplate("yyyyMMMM") - var newSections = [PaymentsSection]() + var newSectionMap = [String: PaymentsSection]() for row in allPayments { + // Implementation note: + // In theory, `allPayments` is perfectly sorted according to `row.sortDate`. + // However, if theory != practice, then you could end up with duplicate sections. + // This was the case recently, when the DB query was changed, and the end result + // was a very messed up UI when attempting to scroll. + // + // So it's better to code more defensively, and don't assume perfect sort order. + let date = row.sortDate let comps = calendar.dateComponents([.year, .month], from: date) let year = comps.year! let month = comps.month! - if var lastSection = newSections.last, lastSection.year == year, lastSection.month == month { + let sectionId = "\(year)-\(month)" + if var section = newSectionMap[sectionId] { - lastSection.payments.append(row) - let _ = newSections.popLast() - newSections.append(lastSection) + section.payments.append(row) + newSectionMap[sectionId] = section } else { let name = dateFormatter.string(from: date) var section = PaymentsSection(year: year, month: month, name: name) section.payments.append(row) - newSections.append(section) + newSectionMap[sectionId] = section } } + let newSections = newSectionMap.values.sorted { (a: PaymentsSection, b: PaymentsSection) in + // return true if `a` should be ordered before `b`; otherwise return false + return (a.year > b.year) || (a.year == b.year && a.month > b.month) + } + paymentsPage = page sections = newSections diff --git a/phoenix-ios/phoenix-ios/views/widgets/CameraPicker.swift b/phoenix-ios/phoenix-ios/views/widgets/CameraPicker.swift index 61dc3865e..360f5d6bc 100644 --- a/phoenix-ios/phoenix-ios/views/widgets/CameraPicker.swift +++ b/phoenix-ios/phoenix-ios/views/widgets/CameraPicker.swift @@ -1,7 +1,7 @@ import SwiftUI struct CameraPicker: UIViewControllerRepresentable { - @Binding var image: UIImage? + @Binding var result: PickerResult? func makeUIViewController(context: Context) -> UIImagePickerController { let imagePicker = UIImagePickerController() @@ -31,8 +31,10 @@ struct CameraPicker: UIViewControllerRepresentable { ) { picker.dismiss(animated: true) - guard let selectedImage = info[.originalImage] as? UIImage else { return } - self.parent.image = selectedImage + if let image = info[.originalImage] as? UIImage { + let result = PickerResult(image: image, file: nil) + self.parent.result = result + } } } } diff --git a/phoenix-ios/phoenix-ios/views/widgets/ImagePicker.swift b/phoenix-ios/phoenix-ios/views/widgets/ImagePicker.swift index fe4579e95..35e550b34 100644 --- a/phoenix-ios/phoenix-ios/views/widgets/ImagePicker.swift +++ b/phoenix-ios/phoenix-ios/views/widgets/ImagePicker.swift @@ -1,8 +1,17 @@ import PhotosUI import SwiftUI +fileprivate let filename = "ImagePicker" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + struct ImagePicker: UIViewControllerRepresentable { - @Binding var image: UIImage? + + let copyFile: Bool + @Binding var result: PickerResult? func makeUIViewController(context: Context) -> PHPickerViewController { var config = PHPickerConfiguration() @@ -31,11 +40,57 @@ struct ImagePicker: UIViewControllerRepresentable { guard let provider = results.first?.itemProvider else { return } - if provider.canLoadObject(ofClass: UIImage.self) { - provider.loadObject(ofClass: UIImage.self) { image, _ in - self.parent.image = image as? UIImage + Task { @MainActor in + + var fileUrl: URL? = nil + if #available(iOS 16, *), parent.copyFile { + + if let type = provider.registeredContentTypes(conformingTo: UTType.heic).first { + log.debug("heic: available") + if let url = try? await provider.asyncLoadFileRepresentation(for: type) { + log.debug("heic: loaded") + fileUrl = url + } + } else { + log.debug("heic: NOT available") + } + + if fileUrl == nil { + if let type = provider.registeredContentTypes(conformingTo: UTType.jpeg).first { + log.debug("jpeg: available") + if let url = try? await provider.asyncLoadFileRepresentation(for: type) { + log.debug("jpeg: loaded") + fileUrl = url + } + } else { + log.debug("jpeg: NOT available") + } + } + } + + var fileCopyResult: FileCopyResult? = nil + if let fileUrl { + let keys = Set([.fileSizeKey]) + if let resourceValues = try? fileUrl.resourceValues(forKeys: keys) { + if let fileSize = resourceValues.fileSize { + fileCopyResult = FileCopyResult(url: fileUrl, size: fileSize) + } + } } - } + + if let image = try? await provider.asyncLoadImage() { + self.parent.result = PickerResult(image: image, file: fileCopyResult) + } + + } // } } } + +// Compiler warning: +// > Passing argument of non-sendable type 'NSItemProvider' outside of main +// > actor-isolated context may introduce data races. +// +// This just silences the compiler until Apple marks NSItemProvider as Sendable +// +extension NSItemProvider: @unchecked Sendable { } diff --git a/phoenix-ios/phoenix-ios/views/widgets/PickerResult.swift b/phoenix-ios/phoenix-ios/views/widgets/PickerResult.swift new file mode 100644 index 000000000..f4d38692a --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/widgets/PickerResult.swift @@ -0,0 +1,201 @@ +import Foundation +import UIKit +import AVFoundation + +fileprivate let filename = "PickerResult" +#if DEBUG && true +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + +class PickerResult: Equatable { + + let image: UIImage + let file: FileCopyResult? + + init(image: UIImage, file: FileCopyResult?) { + self.image = image + self.file = file + } + + func downscale(maxWidth: CGFloat = 1_000, maxHeight: CGFloat = 1_000) async -> PickerResult { + + guard image.size.width > maxWidth, image.size.height > maxHeight else { + log.debug("PickerResult.downscale: image too small - downsize not required") + return self + } + + let targetSize = CGSize(width: maxWidth, height: maxHeight) + guard let scaledImage = image.preparingThumbnail(of: targetSize) else { + log.error("PickerResult.downscale: image.preparingThumbnail returned nil") + return self + } + + return PickerResult(image: scaledImage, file: self.file) + } + + func compress() async -> Data? { + + let isSmallerThanExistingFile = { (data: Data) -> Bool in + if let existingFile = self.file { + return data.count < existingFile.size + } else { + return true + } + } + + if let result = compressWithHeic_optionA(), isSmallerThanExistingFile(result) { + return result + } + if let result = compressWithHeic_optionB(), isSmallerThanExistingFile(result) { + return result + } + if let result = compressWithJpeg(), isSmallerThanExistingFile(result) { + return result + } + + return nil + } + + private func compressWithHeic_optionA() -> Data? { + + // UIImage has a method `heicData` that just works. + // Unfortunately it's only available on iOS 17. + // And it doesn't take a compressionQuality parameter as you would expect. + // + // But it does actually work. + // Unlike Apple's buggy and/or undocumented low-level stuff. + + var compressedImageData: Data? = nil + if #available(iOS 17, *) { + compressedImageData = image.heicData() + } + + return compressedImageData + } + + private func compressWithHeic_optionB() -> Data? { + + // We can use CGImageDestination to create the HEIC file. + // But it only seems to work if the image isn't rotated. + // + // Details: + // When you take a photo on the iPhone, the raw data (rows & columns of color information) + // is always stored in the same orientation, according to the hardware of the camera. + // So how does rotation work ? Via metadata. + // A `rotation` flag is stored in the image's metadata. This is later read by software, + // which automatically rotates the image for display on the screen. + // + // The problem we have is that we're unable to properly set this orientation flag. + // You're supposed to be able to use the `kCGImageDestinationOrientation` option. + // But I've tried setting this flag a hundred different ways, and no matter what I do, + // the CGImageDestination code always writes a file with the orientation flag set to 1. + // + // So we're only going to use this option if the bug won't affect us. + + guard image.imageOrientation == .up else { + return nil + } + + let data = NSMutableData() + guard let imageDestination = + CGImageDestinationCreateWithData( + data, AVFileType.heic as CFString, 1, nil + ) else { + log.error("PickerResult.compressWithHeic: heic not supported") + return nil + } + + guard let cgImage = image.cgImage else { + log.error("PickerResult.compressWithHeic: cgImage missing") + return nil + } + + let orientation: CGImagePropertyOrientation = image.imageOrientation.cgImageOrientation + let options: NSDictionary = [ + kCGImageDestinationLossyCompressionQuality as String: NSNumber(value: 0.98), + kCGImageDestinationOrientation as String: NSNumber(value: orientation.rawValue), // does NOT work + ] + + CGImageDestinationAddImageAndMetadata(imageDestination, cgImage, nil, options) + guard CGImageDestinationFinalize(imageDestination) else { + log.error("PickerResult.compressWithHeic: could not finalize") + return nil + } + + return data as Data + } + + private func compressWithJpeg() -> Data? { + + return image.jpegData(compressionQuality: 0.98) + } + + static func == (lhs: PickerResult, rhs: PickerResult) -> Bool { + if lhs.image != rhs.image { + return false + } + if lhs.file != rhs.file { + return false + } + return true + } +} + +class FileCopyResult: Equatable { + + let url: URL + let size: Int // in bytes + + init(url: URL, size: Int) { + self.url = url + self.size = size + } + + deinit { + let fileUrl = url + DispatchQueue.global(qos: .background).async { + do { + if FileManager.default.isDeletableFile(atPath: fileUrl.path) { + try FileManager.default.removeItem(at: fileUrl) + log.info("FileCopyResult.cleanup: deleted temp file") + } else { + log.debug("FileCopyResult.cleanup: unowned temp file") + } + } catch { + log.error("FileCopyResult.cleanup: cannot delete file: \(error)") + } + } + } + + static func == (lhs: FileCopyResult, rhs: FileCopyResult) -> Bool { + if lhs.url != rhs.url { + return false + } + if lhs.size != rhs.size { + return false + } + return true + } +} + +extension UIImage.Orientation { + + // The rawValues for UIImageOrientation do NOT match CGImagePropertyOrientation :( + // https://developer.apple.com/documentation/imageio/cgimagepropertyorientation + // + var cgImageOrientation: CGImagePropertyOrientation { + switch self { + case .up : return CGImagePropertyOrientation.up + case .upMirrored : return CGImagePropertyOrientation.upMirrored + case .down : return CGImagePropertyOrientation.down + case .downMirrored : return CGImagePropertyOrientation.downMirrored + case .left : return CGImagePropertyOrientation.left + case .leftMirrored : return CGImagePropertyOrientation.leftMirrored + case .right : return CGImagePropertyOrientation.right + case .rightMirrored : return CGImagePropertyOrientation.rightMirrored + @unknown default : fatalError("Unknown UIImage.Orientation") + } + } +} diff --git a/phoenix-ios/phoenix-notifySrvExt/NotificationService.swift b/phoenix-ios/phoenix-notifySrvExt/NotificationService.swift index e76fcdb37..2ac1bdff9 100644 --- a/phoenix-ios/phoenix-notifySrvExt/NotificationService.swift +++ b/phoenix-ios/phoenix-notifySrvExt/NotificationService.swift @@ -376,6 +376,7 @@ class NotificationService: UNNotificationServiceExtension { let paymentInfo = WalletPaymentInfo( payment: receivedPayments.first!, metadata: WalletPaymentMetadata.empty(), + contact: nil, fetchOptions: WalletPaymentFetchOptions.companion.None ) if let desc = paymentInfo.paymentDescription(), desc.count > 0 { diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/WalletPayment.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/WalletPayment.kt index af685367d..04a2eb821 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/WalletPayment.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/data/WalletPayment.kt @@ -130,6 +130,7 @@ fun WalletPayment.walletPaymentId(): WalletPaymentId = when (this) { data class WalletPaymentInfo( val payment: WalletPayment, val metadata: WalletPaymentMetadata, + val contact: ContactInfo?, val fetchOptions: WalletPaymentFetchOptions ) { fun id() = payment.walletPaymentId() @@ -191,6 +192,13 @@ data class WalletPaymentFetchOptions(val flags: Int) { // <- bitmask return WalletPaymentFetchOptions(this.flags or other.flags) } + /* The `-` operator is implemented, so it can be used like so: + * `val options = WalletPaymentFetchOptions.All - WalletPaymentFetchOptions.UserNotes` + */ + operator fun minus(other: WalletPaymentFetchOptions): WalletPaymentFetchOptions { + return WalletPaymentFetchOptions(this.flags and other.flags.inv()) + } + fun contains(options: WalletPaymentFetchOptions): Boolean { return (this.flags and options.flags) != 0 } @@ -201,7 +209,8 @@ data class WalletPaymentFetchOptions(val flags: Int) { // <- bitmask val Lnurl = WalletPaymentFetchOptions(1 shl 1) val UserNotes = WalletPaymentFetchOptions(1 shl 2) val OriginalFiat = WalletPaymentFetchOptions(1 shl 3) + val Contact = WalletPaymentFetchOptions(1 shl 4) - val All = Descriptions + Lnurl + UserNotes + OriginalFiat + val All = Descriptions + Lnurl + UserNotes + OriginalFiat + Contact } } \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/MetadataQueries.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/MetadataQueries.kt index 0fb589359..e33c8562d 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/MetadataQueries.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/db/payments/MetadataQueries.kt @@ -44,7 +44,7 @@ class MetadataQueries(val database: PaymentsDatabase) { // - descriptions + originalFiat // - descriptions // Other combinations are uncommon or never used, so remain unoptimized at this point. - return when (options) { + return when (options - WalletPaymentFetchOptions.Contact) { WalletPaymentFetchOptions.None -> { null } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/ContactsManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/ContactsManager.kt index 61a589fa5..95017e70c 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/ContactsManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/ContactsManager.kt @@ -18,17 +18,26 @@ package fr.acinq.phoenix.managers import fr.acinq.bitcoin.ByteVector32 import fr.acinq.bitcoin.PublicKey +import fr.acinq.lightning.db.IncomingPayment +import fr.acinq.lightning.db.WalletPayment import fr.acinq.lightning.logging.LoggerFactory import fr.acinq.lightning.utils.UUID import fr.acinq.lightning.wire.OfferTypes import fr.acinq.phoenix.PhoenixBusiness import fr.acinq.phoenix.data.ContactInfo import fr.acinq.phoenix.db.SqliteAppDb +import fr.acinq.phoenix.utils.extensions.incomingOfferMetadata +import fr.acinq.phoenix.utils.extensions.outgoingInvoiceRequest import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.MainScope import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.flatMap +import kotlinx.coroutines.flow.flatMapConcat import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.mapLatest +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -46,6 +55,27 @@ class ContactsManager( private val _contactsList = MutableStateFlow>(emptyList()) val contactsList = _contactsList.asStateFlow() + + val contactsMap = _contactsList.map { list -> + list.associateBy { it.id } + }.stateIn(scope = this, started = SharingStarted.Eagerly, initialValue = emptyMap()) + + val offerMap = _contactsList.map { list -> + list.flatMap { contact -> + contact.offers.map { offer -> + offer to contact.id + } + }.toMap() + }.stateIn(scope = this, started = SharingStarted.Eagerly, initialValue = emptyMap()) + + val publicKeyMap = _contactsList.map { list -> + list.flatMap { contact -> + contact.publicKeys.map { pubKey -> + pubKey to contact.id + } + }.toMap() + }.stateIn(scope = this, started = SharingStarted.Eagerly, initialValue = emptyMap()) + val contactsWithOfferList = _contactsList.map { contacts -> contacts.filter { it.offers.isNotEmpty() } } @@ -92,4 +122,30 @@ class ContactsManager( suspend fun detachOfferFromContact(offerId: ByteVector32) { appDb.deleteOfferContactLink(offerId) } + + /** + * In many cases there's no need to query the database since we have everything in memory. + */ + + fun contactForId(contactId: UUID): ContactInfo? { + return contactsMap.value[contactId] + } + + fun contactIdForPayment(payment: WalletPayment): UUID? { + return if (payment is IncomingPayment) { + payment.incomingOfferMetadata()?.let { offerMetadata -> + publicKeyMap.value[offerMetadata.payerKey] + } + } else { + payment.outgoingInvoiceRequest()?.let {invoiceRequest -> + offerMap.value[invoiceRequest.offer] + } + } + } + + fun contactForPayment(payment: WalletPayment): ContactInfo? { + return contactIdForPayment(payment)?.let { contactId -> + contactForId(contactId) + } + } } \ No newline at end of file diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PaymentsManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PaymentsManager.kt index 44ad2955e..d5cd14fab 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PaymentsManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/PaymentsManager.kt @@ -3,7 +3,6 @@ package fr.acinq.phoenix.managers import fr.acinq.bitcoin.TxId import fr.acinq.lightning.blockchain.electrum.ElectrumClient import fr.acinq.lightning.db.InboundLiquidityOutgoingPayment -import fr.acinq.lightning.db.IncomingPayment import fr.acinq.lightning.db.LightningOutgoingPayment import fr.acinq.lightning.db.SpliceCpfpOutgoingPayment import fr.acinq.lightning.db.WalletPayment @@ -25,7 +24,7 @@ import kotlinx.coroutines.launch class PaymentsManager( private val loggerFactory: LoggerFactory, private val configurationManager: AppConfigurationManager, - private val peerManager: PeerManager, + private val contactsManager: ContactsManager, private val databaseManager: DatabaseManager, private val electrumClient: ElectrumClient, ) : CoroutineScope by MainScope() { @@ -33,7 +32,7 @@ class PaymentsManager( constructor(business: PhoenixBusiness) : this( loggerFactory = business.loggerFactory, configurationManager = business.appConfigurationManager, - peerManager = business.peerManager, + contactsManager = business.contactsManager, databaseManager = business.databaseManager, electrumClient = business.electrumClient ) @@ -166,9 +165,14 @@ class PaymentsManager( is WalletPaymentId.SpliceCpfpOutgoingPaymentId -> paymentsDb().getSpliceCpfpOutgoingPayment(id.id, options) is WalletPaymentId.InboundLiquidityOutgoingPaymentId -> paymentsDb().getInboundLiquidityOutgoingPayment(id.id, options) }?.let { + val payment = it.first + val contact = if (options.contains(WalletPaymentFetchOptions.Contact)) { + contactsManager.contactForPayment(payment) + } else { null } WalletPaymentInfo( - payment = it.first, + payment = payment, metadata = it.second ?: WalletPaymentMetadata(), + contact = contact, fetchOptions = options ) } diff --git a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitDb.kt b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitDb.kt index 76947875c..e598286f3 100644 --- a/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitDb.kt +++ b/phoenix-shared/src/iosMain/kotlin/fr/acinq/phoenix/db/CloudKitDb.kt @@ -156,6 +156,7 @@ class CloudKitDb( rowMap[paymentId] = WalletPaymentInfo( payment = payment, metadata = metadataPlaceholder, + contact = null, fetchOptions = emptyOptions ) } @@ -170,6 +171,7 @@ class CloudKitDb( rowMap[paymentId] = WalletPaymentInfo( payment = payment, metadata = metadataPlaceholder, + contact = null, fetchOptions = emptyOptions ) } @@ -184,6 +186,7 @@ class CloudKitDb( rowMap[paymentId] = WalletPaymentInfo( payment = payment, metadata = metadataPlaceholder, + contact = null, fetchOptions = emptyOptions ) } @@ -198,6 +201,7 @@ class CloudKitDb( rowMap[paymentId] = WalletPaymentInfo( payment = payment, metadata = metadataPlaceholder, + contact = null, fetchOptions = emptyOptions ) } @@ -212,6 +216,7 @@ class CloudKitDb( rowMap[paymentId] = WalletPaymentInfo( payment = payment, metadata = metadataPlaceholder, + contact = null, fetchOptions = emptyOptions ) } @@ -226,17 +231,19 @@ class CloudKitDb( rowMap[paymentId] = WalletPaymentInfo( payment = payment, metadata = metadataPlaceholder, + contact = null, fetchOptions = emptyOptions ) } } // - val fetchOptions = WalletPaymentFetchOptions.All + val fetchOptions = WalletPaymentFetchOptions.All - WalletPaymentFetchOptions.Contact uniquePaymentIds.forEach { paymentId -> metaQueries.getMetadata(paymentId, fetchOptions)?.let { metadata -> rowMap[paymentId]?.let { rowMap[paymentId] = it.copy( metadata = metadata, + contact = null, fetchOptions = fetchOptions ) } From 4a75530a09e0890a7d31759f2e290e619cc2cbeb Mon Sep 17 00:00:00 2001 From: Robbie Hanson <304604+robbiehanson@users.noreply.github.com> Date: Wed, 24 Jul 2024 12:01:45 -0500 Subject: [PATCH 02/13] (ios) Fixing errors post-rebase --- .../danger zone/drain wallet/BtcAddressInput.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phoenix-ios/phoenix-ios/views/configuration/danger zone/drain wallet/BtcAddressInput.swift b/phoenix-ios/phoenix-ios/views/configuration/danger zone/drain wallet/BtcAddressInput.swift index ef5615e2d..a3001658a 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/danger zone/drain wallet/BtcAddressInput.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/danger zone/drain wallet/BtcAddressInput.swift @@ -116,7 +116,7 @@ struct BtcAddressInput: View { let isScannedValue = textFieldValue == scannedValue let business = Biz.business - let parseResult = Parser.shared.readBitcoinAddress(chain: business.chain, input: textFieldValue) + let parseResult = Parser.shared.parseBip21Uri(chain: business.chain, input: textFieldValue) if let error = parseResult.left { From 5c394168f9bbd6f05fe4a7a3aee6a1e4fd2cb5ec Mon Sep 17 00:00:00 2001 From: Robbie Hanson <304604+robbiehanson@users.noreply.github.com> Date: Thu, 25 Jul 2024 12:46:32 -0500 Subject: [PATCH 03/13] Resolving flicker when displaying Contacts list --- phoenix-ios/phoenix-ios/views/contacts/ContactsList.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phoenix-ios/phoenix-ios/views/contacts/ContactsList.swift b/phoenix-ios/phoenix-ios/views/contacts/ContactsList.swift index 97de33161..dde27b17e 100644 --- a/phoenix-ios/phoenix-ios/views/contacts/ContactsList.swift +++ b/phoenix-ios/phoenix-ios/views/contacts/ContactsList.swift @@ -94,7 +94,7 @@ struct ContactsList: View { } } // .listStyle(.plain) - .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .automatic)) + .searchable(text: $searchText, placement: .navigationBarDrawer(displayMode: .always)) .confirmationDialog("Delete contact?", isPresented: confirmationDialogBinding(), titleVisibility: Visibility.automatic From 7f680afc2eca160781e94d1ce9643146457b3391 Mon Sep 17 00:00:00 2001 From: Robbie Hanson <304604+robbiehanson@users.noreply.github.com> Date: Thu, 25 Jul 2024 12:47:41 -0500 Subject: [PATCH 04/13] Fixing name mismatch: "contacts" vs "address book" --- phoenix-ios/phoenix-ios/views/contacts/ContactsList.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phoenix-ios/phoenix-ios/views/contacts/ContactsList.swift b/phoenix-ios/phoenix-ios/views/contacts/ContactsList.swift index dde27b17e..179447316 100644 --- a/phoenix-ios/phoenix-ios/views/contacts/ContactsList.swift +++ b/phoenix-ios/phoenix-ios/views/contacts/ContactsList.swift @@ -46,7 +46,7 @@ struct ContactsList: View { .navigationStackDestination(isPresented: selectedItemBinding()) { // For iOS 16+ selectedItemView() } - .navigationTitle("Address Book") + .navigationTitle("Contacts") .navigationBarTitleDisplayMode(.inline) .navigationBarItems(trailing: plusButton()) } From 5c6f43b0d4782a2648c9019cfe9591b1e7dd5e6b Mon Sep 17 00:00:00 2001 From: Robbie Hanson <304604+robbiehanson@users.noreply.github.com> Date: Thu, 25 Jul 2024 14:50:57 -0500 Subject: [PATCH 05/13] Updating color of placeholder user image --- phoenix-ios/phoenix-ios/Localizable.xcstrings | 3 --- phoenix-ios/phoenix-ios/views/contacts/ContactPhoto.swift | 2 +- phoenix-ios/phoenix-ios/views/contacts/ManageContact.swift | 2 +- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/phoenix-ios/phoenix-ios/Localizable.xcstrings b/phoenix-ios/phoenix-ios/Localizable.xcstrings index db436079a..f69aef3cf 100644 --- a/phoenix-ios/phoenix-ios/Localizable.xcstrings +++ b/phoenix-ios/phoenix-ios/Localizable.xcstrings @@ -5084,9 +5084,6 @@ } } } - }, - "Address Book" : { - }, "address type" : { "comment" : "Label in DetailsView_IncomingPayment", diff --git a/phoenix-ios/phoenix-ios/views/contacts/ContactPhoto.swift b/phoenix-ios/phoenix-ios/views/contacts/ContactPhoto.swift index c794f1609..6f86599e8 100644 --- a/phoenix-ios/phoenix-ios/views/contacts/ContactPhoto.swift +++ b/phoenix-ios/phoenix-ios/views/contacts/ContactPhoto.swift @@ -76,7 +76,7 @@ fileprivate struct _ContactPhoto: View { Image(systemName: "person.circle") .resizable() .aspectRatio(contentMode: .fit) - .foregroundColor(.gray) + .foregroundColor(Color(UIColor.systemGray3)) } } .frame(width: size, height: size) diff --git a/phoenix-ios/phoenix-ios/views/contacts/ManageContact.swift b/phoenix-ios/phoenix-ios/views/contacts/ManageContact.swift index 4f6f0ca5d..cc2f29635 100644 --- a/phoenix-ios/phoenix-ios/views/contacts/ManageContact.swift +++ b/phoenix-ios/phoenix-ios/views/contacts/ManageContact.swift @@ -322,7 +322,7 @@ struct ManageContact: View { Image(systemName: "person.circle") .resizable() .aspectRatio(contentMode: .fit) - .foregroundColor(.gray) + .foregroundColor(Color(UIColor.systemGray3)) } } .frame(width: IMG_SIZE, height: IMG_SIZE) From 3a900b39f6e281fe68d5860422ee7c267d35b625 Mon Sep 17 00:00:00 2001 From: Robbie Hanson <304604+robbiehanson@users.noreply.github.com> Date: Thu, 25 Jul 2024 15:00:23 -0500 Subject: [PATCH 06/13] The Contacts button is now displayed by default on the Send screen --- phoenix-ios/phoenix-ios/views/send/ScanView.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/phoenix-ios/phoenix-ios/views/send/ScanView.swift b/phoenix-ios/phoenix-ios/views/send/ScanView.swift index 6aed02aef..0d9ccd161 100644 --- a/phoenix-ios/phoenix-ios/views/send/ScanView.swift +++ b/phoenix-ios/phoenix-ios/views/send/ScanView.swift @@ -182,6 +182,13 @@ struct ScanView: View { .padding(.horizontal, 20) .padding(.top, 20) .padding(.bottom, 12) + .accessibilitySortPriority(4) + + Divider() + + menuOption_contacts() + .padding(.horizontal, 20) + .padding(.vertical, 12) .accessibilitySortPriority(3) if showingFullMenu || voiceOverEnabled { @@ -189,13 +196,6 @@ struct ScanView: View { Divider() - menuOption_contacts() - .padding(.horizontal, 20) - .padding(.vertical, 12) - .accessibilitySortPriority(2) - - Divider() - menuOption_chooseImage() .padding(.horizontal, 20) .padding(.vertical, 12) From 523da7110e4beaa8d70c84f05f0373713d9117da Mon Sep 17 00:00:00 2001 From: Robbie Hanson <304604+robbiehanson@users.noreply.github.com> Date: Thu, 25 Jul 2024 15:25:12 -0500 Subject: [PATCH 07/13] In the Confirm payment screen, the labels were in full caps which was not consistent with the other screens. Also the Contact details was not aligned with the "Send to" label. --- phoenix-ios/phoenix-ios/Localizable.xcstrings | 9 +++++++++ .../phoenix-ios/views/send/PaymentDetails.swift | 15 +++++++-------- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/phoenix-ios/phoenix-ios/Localizable.xcstrings b/phoenix-ios/phoenix-ios/Localizable.xcstrings index f69aef3cf..05d4375ba 100644 --- a/phoenix-ios/phoenix-ios/Localizable.xcstrings +++ b/phoenix-ios/phoenix-ios/Localizable.xcstrings @@ -22541,6 +22541,9 @@ } } } + }, + "Lightning fee" : { + }, "Lightning fee amount: (percent), (amountBitcoin), ≈(amountFiat)" : { "comment" : "VoiceOver label: PaymentSummaryView", @@ -39179,6 +39182,9 @@ } } } + }, + "Tip" : { + }, "Tip amount: (percent), (amountBitcoin), ≈(amountFiat)" : { "comment" : "VoiceOver label: PaymentSummaryView", @@ -39856,6 +39862,9 @@ } } } + }, + "Total" : { + }, "total amount" : { "comment" : "Label in DetailsView_IncomingPayment", diff --git a/phoenix-ios/phoenix-ios/views/send/PaymentDetails.swift b/phoenix-ios/phoenix-ios/views/send/PaymentDetails.swift index 4df09ca79..ab2493418 100644 --- a/phoenix-ios/phoenix-ios/views/send/PaymentDetails.swift +++ b/phoenix-ios/phoenix-ios/views/send/PaymentDetails.swift @@ -160,7 +160,7 @@ struct PaymentDetails: View { if info.hasTip { GridRowWrapper(gridWidth: gridWidth) { - titleColumn("tip", titleColor) + titleColumn("Tip", titleColor) } valueColumn: { VStack(alignment: HorizontalAlignment.leading, spacing: 4) { Text(verbatim: info.bitcoin_tip.string) @@ -177,7 +177,7 @@ struct PaymentDetails: View { if info.hasLightningFee || info.isEmpty { GridRowWrapper(gridWidth: gridWidth) { - titleColumn("lightning fee", titleColor) + titleColumn("Lightning fee", titleColor) } valueColumn: { VStack(alignment: HorizontalAlignment.leading, spacing: 4) { Text(verbatim: info.bitcoin_lightningFee.string) @@ -194,7 +194,7 @@ struct PaymentDetails: View { if info.hasMinerFee { GridRowWrapper(gridWidth: gridWidth) { - titleColumn("miner fee", titleColor) + titleColumn("Miner fee", titleColor) } valueColumn: { HStack(alignment: VerticalAlignment.center, spacing: 4) { @@ -221,7 +221,7 @@ struct PaymentDetails: View { if info.hasTip || info.hasLightningFee || info.hasMinerFee || info.isEmpty { GridRowWrapper(gridWidth: gridWidth) { - titleColumn("total", titleColor) + titleColumn("Total", titleColor) } valueColumn: { VStack(alignment: HorizontalAlignment.leading, spacing: 4) { Text(verbatim: info.bitcoin_total.string) @@ -243,7 +243,6 @@ struct PaymentDetails: View { ) -> some View { Text(title) - .textCase(.uppercase) .foregroundColor(color) } @@ -433,13 +432,13 @@ struct GridRowWrapper: View { GridRow(alignment: VerticalAlignment.firstTextBaseline) { keyColumn .font(.subheadline) - .frame(maxWidth: columnWidth, alignment: Alignment.topTrailing) - .gridCellAnchor(.topTrailing) + .frame(maxWidth: columnWidth, alignment: Alignment.trailing) + .gridColumnAlignment(HorizontalAlignment.trailing) valueColumn .font(.subheadline) .frame(minWidth: columnWidth, alignment: Alignment.leading) - .gridCellAnchor(.leading) + .gridColumnAlignment(HorizontalAlignment.leading) } } From 127bdca8bd5c8b198b2d4a5ec2ecbe137e65e91c Mon Sep 17 00:00:00 2001 From: Robbie Hanson <304604+robbiehanson@users.noreply.github.com> Date: Thu, 25 Jul 2024 16:08:13 -0500 Subject: [PATCH 08/13] Reworking the cancel/save options in the navigation bar. Now it's more in-line with typical Apple design. --- phoenix-ios/phoenix-ios/Localizable.xcstrings | 3 + .../views/contacts/ManageContact.swift | 155 +++++++++--------- 2 files changed, 76 insertions(+), 82 deletions(-) diff --git a/phoenix-ios/phoenix-ios/Localizable.xcstrings b/phoenix-ios/phoenix-ios/Localizable.xcstrings index 05d4375ba..955237a84 100644 --- a/phoenix-ios/phoenix-ios/Localizable.xcstrings +++ b/phoenix-ios/phoenix-ios/Localizable.xcstrings @@ -31935,6 +31935,9 @@ } } } + }, + "Save changes" : { + }, "Save it somewhere safe (not on this phone). If you lose your seed and your phone, you've lost your funds." : { "extractionState" : "manual", diff --git a/phoenix-ios/phoenix-ios/views/contacts/ManageContact.swift b/phoenix-ios/phoenix-ios/views/contacts/ManageContact.swift index cc2f29635..0678cb1e8 100644 --- a/phoenix-ios/phoenix-ios/views/contacts/ManageContact.swift +++ b/phoenix-ios/phoenix-ios/views/contacts/ManageContact.swift @@ -116,7 +116,7 @@ struct ManageContact: View { .navigationTitle(self.title) .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden(true) - .navigationBarItems(leading: header_backButton(), trailing: header_trailingButtons()) + .navigationBarItems(leading: header_cancelButton(), trailing: header_doneButton()) .background( Color.primaryBackground.ignoresSafeArea(.all, edges: .bottom) ) @@ -169,7 +169,7 @@ struct ManageContact: View { titleVisibility: Visibility.hidden ) { Button("Discard changes", role: ButtonRole.destructive) { - discardChanges() + close() } } .confirmationDialog("Delete contact?", @@ -218,9 +218,9 @@ struct ManageContact: View { func header_sheet() -> some View { HStack(alignment: VerticalAlignment.center, spacing: 0) { - header_backButton() + header_cancelButton() Spacer() - header_trailingButtons() + header_doneButton() } .padding() } @@ -233,55 +233,27 @@ struct ManageContact: View { } @ViewBuilder - func header_backButton() -> some View { + func header_cancelButton() -> some View { Button { - saveButtonTapped() + cancelButtonTapped() } label: { - HStack(alignment: .center, spacing: 4) { - Image(systemName: "chevron.backward") - .imageScale(.medium) - .font(.headline.weight(.semibold)) - if hasChanges() { - if canSave() { - Text("Save").font(.title3) - } else { - Text("Cancel").font(.title3) - } - } - } + Text("Cancel").font(.headline) } .disabled(isSaving) + .accessibilityLabel("Discard changes") } @ViewBuilder - func header_trailingButtons() -> some View { + func header_doneButton() -> some View { - if !isNewContact { - HStack(alignment: VerticalAlignment.center, spacing: 10) { - Button { - showDiscardChangesConfirmationDialog = true - } label: { - Image(systemName: "eraser") - .imageScale(.medium) - .font(.title2) - .foregroundColor(.gray) - } - .disabled(!hasChanges()) - .accessibilityLabel("Discard changes") - - Button { - showDeleteContactConfirmationDialog = true - } label: { - Image(systemName: "trash.fill") - .imageScale(.medium) - .font(.title2) - .foregroundColor(.appNegative) - } - .disabled(isSaving) - .accessibilityLabel("Delete contact") - } + Button { + saveButtonTapped() + } label: { + Text("Done").font(.headline) } + .disabled(!hasChanges || !canSave || isSaving) + .accessibilityLabel("Save changes") } @ViewBuilder @@ -480,7 +452,7 @@ struct ManageContact: View { } } // - .padding(.bottom) + .padding(.bottom, 30) } @ViewBuilder @@ -514,7 +486,7 @@ struct ManageContact: View { } } // - .padding(.bottom) + .padding(.bottom, 30) .onChange(of: pastedOffer) { _ in pastedOfferChanged() } @@ -525,6 +497,8 @@ struct ManageContact: View { if case .smartModal = location { footer_smartModal() + } else { + footer_navStack() } } @@ -563,13 +537,35 @@ struct ManageContact: View { .buttonStyle(.bordered) .buttonBorderShape(.capsule) .foregroundColor(hasName ? Color.appPositive : Color.appPositive.opacity(0.6)) - .disabled(isSaving || !canSave()) + .disabled(isSaving || !canSave) } // .padding() .assignMaxPreference(for: maxFooterButtonWidthReader.key, to: $maxFooterButtonWidth) } + @ViewBuilder + func footer_navStack() -> some View { + + if !isNewContact { + HStack(alignment: VerticalAlignment.centerTopLine, spacing: 0) { + Spacer(minLength: 0) + + Button { + showDeleteContactConfirmationDialog = true + } label: { + // Text("Delete contact") + Label("Delete contact", systemImage: "trash.fill") + .foregroundColor(.appNegative) + } + .disabled(isSaving) + + Spacer(minLength: 0) + } // + .padding(.bottom) + } + } + // -------------------------------------------------- // MARK: View Helpers // -------------------------------------------------- @@ -658,30 +654,7 @@ struct ManageContact: View { return (offer == nil) && (contact == nil) } - func offerRows() -> [OfferRow] { - - var offers = Set() - var results = Array() - - if let offer { - let offerStr = offer.encode() - offers.insert(offerStr) - results.append(OfferRow(offer: offerStr, isCurrentOffer: true)) - } - if let contact { - for offer in contact.offers { - let offerStr = offer.encode() - if !offers.contains(offerStr) { - offers.insert(offerStr) - results.append(OfferRow(offer: offerStr, isCurrentOffer: false)) - } - } - } - - return results - } - - func hasChanges() -> Bool { + var hasChanges: Bool { if let contact { if name != contact.name { @@ -704,7 +677,7 @@ struct ManageContact: View { } } - func canSave() -> Bool { + var canSave: Bool { if !hasName { return false @@ -718,6 +691,29 @@ struct ManageContact: View { return true } + func offerRows() -> [OfferRow] { + + var offers = Set() + var results = Array() + + if let offer { + let offerStr = offer.encode() + offers.insert(offerStr) + results.append(OfferRow(offer: offerStr, isCurrentOffer: true)) + } + if let contact { + for offer in contact.offers { + let offerStr = offer.encode() + if !offers.contains(offerStr) { + offers.insert(offerStr) + results.append(OfferRow(offer: offerStr, isCurrentOffer: false)) + } + } + } + + return results + } + // -------------------------------------------------- // MARK: Notifications // -------------------------------------------------- @@ -797,28 +793,23 @@ struct ManageContact: View { func cancelButtonTapped() { log.trace("cancelButtonTapped") - close() + if hasChanges && canSave { + showDiscardChangesConfirmationDialog = true + } else { + close() + } } func saveButtonTapped() { log.trace("saveButtonTapped()") - if hasChanges() && canSave() { + if hasChanges && canSave { saveContact() } else { close() } } - func discardChanges() { - log.trace("discardChages()") - - name = contact?.name ?? "" - pickerResult = nil - doNotUseDiskImage = false - trustedContact = contact?.useOfferKey ?? DEFAULT_TRUSTED - } - func saveContact() { log.trace("saveContact()") From 70c1d5f221f597930502630c1d041d94152a957b Mon Sep 17 00:00:00 2001 From: Robbie Hanson <304604+robbiehanson@users.noreply.github.com> Date: Thu, 25 Jul 2024 16:22:17 -0500 Subject: [PATCH 09/13] Updating some strings in Spanish --- phoenix-ios/phoenix-ios/Localizable.xcstrings | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/phoenix-ios/phoenix-ios/Localizable.xcstrings b/phoenix-ios/phoenix-ios/Localizable.xcstrings index 955237a84..0fb9e591b 100644 --- a/phoenix-ios/phoenix-ios/Localizable.xcstrings +++ b/phoenix-ios/phoenix-ios/Localizable.xcstrings @@ -14752,7 +14752,7 @@ "es-419" : { "stringUnit" : { "state" : "translated", - "value" : "Donar" + "value" : "Listo" } }, "fr" : { From 73a929932fdb89c996ddc426e6bc702aad849181 Mon Sep 17 00:00:00 2001 From: Robbie Hanson <304604+robbiehanson@users.noreply.github.com> Date: Mon, 29 Jul 2024 17:31:57 -0500 Subject: [PATCH 10/13] Fixing compiler issues in unit test --- .../fr/acinq/phoenix/utils/CsvWriterTests.kt | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/CsvWriterTests.kt b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/CsvWriterTests.kt index b459dcd6b..3fabedb5c 100644 --- a/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/CsvWriterTests.kt +++ b/phoenix-shared/src/commonTest/kotlin/fr/acinq/phoenix/utils/CsvWriterTests.kt @@ -62,7 +62,7 @@ class CsvWriterTests { val expected = "2023-02-01T16:51:12.445Z,12000000,-3000000,2.7599 USD,-0.6899 USD,Incoming LN payment,L2 Top-up,Via Lightning network\r\n" val actual = CsvWriter.makeRow( - info = WalletPaymentInfo(payment, metadata, WalletPaymentFetchOptions.All), + info = WalletPaymentInfo(payment, metadata, null, WalletPaymentFetchOptions.All), localizedDescription = "L2 Top-up", config = makeConfig() ) @@ -94,7 +94,7 @@ class CsvWriterTests { val expected = "2023-02-01T16:54:44.965Z,2173929,0,0.4999 USD,0.0000 USD,Incoming LN payment,Cafécito,\r\n" val actual = CsvWriter.makeRow( - info = WalletPaymentInfo(payment, metadata, WalletPaymentFetchOptions.All), + info = WalletPaymentInfo(payment, metadata, null, WalletPaymentFetchOptions.All), localizedDescription = "Cafécito", config = makeConfig() ) @@ -125,7 +125,7 @@ class CsvWriterTests { val expected = "2023-02-01T16:56:22.248Z,-4354435,-3435,-1.0015 USD,-0.0007 USD,Outgoing LN payment to ${pr.nodeId.toHex()},Arepa de Choclo,Con quesito\r\n" val actual = CsvWriter.makeRow( - info = WalletPaymentInfo(payment, metadata, WalletPaymentFetchOptions.All), + info = WalletPaymentInfo(payment, metadata, null, WalletPaymentFetchOptions.All), localizedDescription = "Arepa de Choclo", config = makeConfig() ) @@ -156,7 +156,7 @@ class CsvWriterTests { val expected = "2023-02-01T16:58:01.099Z,-103010,-3010,-0.0236 USD,-0.0006 USD,Outgoing LN payment to ${pr.nodeId.toHex()},Test 1,\"This note, um, has a comma\"\r\n" val actual = CsvWriter.makeRow( - info = WalletPaymentInfo(payment, metadata, WalletPaymentFetchOptions.All), + info = WalletPaymentInfo(payment, metadata, null, WalletPaymentFetchOptions.All), localizedDescription = "Test 1", config = makeConfig() ) @@ -187,7 +187,7 @@ class CsvWriterTests { val expected = "2023-02-01T16:59:00.742Z,-103010,-3010,-0.0236 USD,-0.0006 USD,Outgoing LN payment to ${pr.nodeId.toHex()},Test 2,\"This \"\"note\"\" has quotes\"\r\n" val actual = CsvWriter.makeRow( - info = WalletPaymentInfo(payment, metadata, WalletPaymentFetchOptions.All), + info = WalletPaymentInfo(payment, metadata, null, WalletPaymentFetchOptions.All), localizedDescription = "Test 2", config = makeConfig() ) @@ -221,7 +221,7 @@ class CsvWriterTests { "Cheddar\n" + "Asiago\"\r\n" val actual = CsvWriter.makeRow( - info = WalletPaymentInfo(payment, metadata, WalletPaymentFetchOptions.All), + info = WalletPaymentInfo(payment, metadata, null, WalletPaymentFetchOptions.All), localizedDescription = "Test 3", config = makeConfig() ) @@ -258,7 +258,7 @@ class CsvWriterTests { val expected = "2023-02-01T17:14:43.668Z,12000000,-3000000,2.7599 USD,-0.6899 USD,Swap-in with inputs: [${input.txid}],L1 Top-up,Via dual-funding flow\r\n" val actual = CsvWriter.makeRow( - info = WalletPaymentInfo(payment, metadata, WalletPaymentFetchOptions.All), + info = WalletPaymentInfo(payment, metadata, null, WalletPaymentFetchOptions.All), localizedDescription = "L1 Top-up", config = makeConfig() ) @@ -293,7 +293,7 @@ class CsvWriterTests { val expected = "2023-02-01T22:16:54.498Z,-12820000,-2820000,-3.0366 USD,-0.6679 USD,Swap-out to tb1qlywh0dk40k87gqphpfs8kghd96hmnvus7r8hhf,Swap for cash,\r\n" val actual = CsvWriter.makeRow( - info = WalletPaymentInfo(payment, metadata, WalletPaymentFetchOptions.All), + info = WalletPaymentInfo(payment, metadata, null, WalletPaymentFetchOptions.All), localizedDescription = "Swap for cash", config = makeConfig() ) @@ -323,7 +323,7 @@ class CsvWriterTests { val expected = "2023-02-02T15:58:53.694Z,-10090000,-1400000,-2.3875 USD,-0.3312 USD,Channel closing to tb1qz5gxe2450uadavle8wwcc5ngquqfj5xp4dy0ja,Channel closing,\r\n" val actual = CsvWriter.makeRow( - info = WalletPaymentInfo(payment, metadata, WalletPaymentFetchOptions.All), + info = WalletPaymentInfo(payment, metadata, null, WalletPaymentFetchOptions.All), localizedDescription = "Channel closing", config = makeConfig() ) From 0721576e7c88e5d934cce20fa2baa7ce2926f9b6 Mon Sep 17 00:00:00 2001 From: Robbie Hanson <304604+robbiehanson@users.noreply.github.com> Date: Mon, 29 Jul 2024 17:32:26 -0500 Subject: [PATCH 11/13] (ios) Updating EditInfo screen to match new Cancel/Done design used in Contacts --- phoenix-ios/phoenix-ios/Localizable.xcstrings | 2 + .../views/inspect/EditInfoView.swift | 116 ++++++++++-------- 2 files changed, 68 insertions(+), 50 deletions(-) diff --git a/phoenix-ios/phoenix-ios/Localizable.xcstrings b/phoenix-ios/phoenix-ios/Localizable.xcstrings index 0fb9e591b..d53bee33d 100644 --- a/phoenix-ios/phoenix-ios/Localizable.xcstrings +++ b/phoenix-ios/phoenix-ios/Localizable.xcstrings @@ -14172,6 +14172,7 @@ }, "Discard Changes" : { + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { @@ -15085,6 +15086,7 @@ }, "Edit Payment" : { "comment" : "Navigation bar title", + "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { diff --git a/phoenix-ios/phoenix-ios/views/inspect/EditInfoView.swift b/phoenix-ios/phoenix-ios/views/inspect/EditInfoView.swift index ae496d180..eb2afe69b 100644 --- a/phoenix-ios/phoenix-ios/views/inspect/EditInfoView.swift +++ b/phoenix-ios/phoenix-ios/views/inspect/EditInfoView.swift @@ -26,7 +26,7 @@ struct EditInfoView: View { let maxNotesCount: Int = 280 @State var remainingNotesCount: Int - @State var hasChanges = false + @State var showDiscardChangesConfirmationDialog: Bool = false @Environment(\.presentationMode) var presentationMode: Binding @@ -67,16 +67,16 @@ struct EditInfoView: View { switch location { case .sheet: main() - .navigationTitle(NSLocalizedString("Edit Info", comment: "Navigation bar title")) + .navigationTitle(String(localized: "Edit Info", comment: "Navigation bar title")) .navigationBarTitleDisplayMode(.inline) .navigationBarHidden(true) case .embedded: main() - .navigationTitle(NSLocalizedString("Edit Payment", comment: "Navigation bar title")) + .navigationTitle(String(localized: "Edit Info", comment: "Navigation bar title")) .navigationBarTitleDisplayMode(.inline) .navigationBarBackButtonHidden(true) - .navigationBarItems(leading: saveButton()) + .navigationBarItems(leading: header_cancelButton(), trailing: header_doneButton()) .background( Color.primaryBackground.ignoresSafeArea(.all, edges: .bottom) ) @@ -87,25 +87,63 @@ struct EditInfoView: View { func main() -> some View { VStack(alignment: HorizontalAlignment.center, spacing: 0) { + header() + ScrollView { + content() + } + } + } + + @ViewBuilder + func header() -> some View { + Group { switch location { case .sheet: HStack(alignment: VerticalAlignment.center, spacing: 0) { - saveButton() + header_cancelButton() Spacer() + header_doneButton() } .padding() case .embedded: Spacer().frame(height: 25) } - - ScrollView { - content() + } + .confirmationDialog("Discard changes?", + isPresented: $showDiscardChangesConfirmationDialog, + titleVisibility: Visibility.hidden + ) { + Button("Discard changes", role: ButtonRole.destructive) { + close() } } } + @ViewBuilder + func header_cancelButton() -> some View { + + Button { + cancelButtonTapped() + } label: { + Text("Cancel").font(.headline) + } + .accessibilityLabel("Discard changes") + } + + @ViewBuilder + func header_doneButton() -> some View { + + Button { + saveButtonTapped() + } label: { + Text("Done").font(.headline) + } + .disabled(!hasChanges) + .accessibilityLabel("Save changes") + } + @ViewBuilder func content() -> some View { @@ -177,14 +215,6 @@ struct EditInfoView: View { } .padding([.leading, .trailing], 8) .padding(.top, 4) - - Button { - discardButtonTapped() - } label: { - Text("Discard Changes") - } - .disabled(!hasChanges) - .padding(.top, 8) } .padding(.top) .padding([.leading, .trailing]) @@ -196,31 +226,20 @@ struct EditInfoView: View { } } - @ViewBuilder - func saveButton() -> some View { - - Button { - saveButtonTapped() - } label: { - HStack(alignment: .center, spacing: 4) { - Image(systemName: "chevron.backward") - .imageScale(.medium) - .font(.headline.weight(.semibold)) - Text("Save") - .font(.title3) - } - } - } - // -------------------------------------------------- // MARK: View Helpers // -------------------------------------------------- - func updateHasChanges() { + var hasChanges: Bool { + + if descText != (originalDescText ?? "") { + return true + } + if notesText != (originalNotesText ?? "") { + return true + } - hasChanges = - descText != (originalDescText ?? "") || - notesText != (originalNotesText ?? "") + return false } // -------------------------------------------------- @@ -231,35 +250,26 @@ struct EditInfoView: View { log.trace("descTextDidChange()") remainingDescCount = maxDescCount - newText.count - updateHasChanges() } func notesTextDidChange(_ newText: String) { log.trace("notesTextDidChange()") remainingNotesCount = maxNotesCount - newText.count - updateHasChanges() } // -------------------------------------------------- // MARK: Actions // -------------------------------------------------- - func discardButtonTapped() { - log.trace("discardButtonTapped()") + func cancelButtonTapped() { + log.trace("cancelButtonTapped()") - let realizedDesc = originalDescText ?? "" - if realizedDesc == defaultDescText { - descText = "" - remainingDescCount = maxDescCount + if hasChanges { + showDiscardChangesConfirmationDialog = true } else { - descText = realizedDesc - remainingDescCount = maxDescCount - realizedDesc.count + close() } - - let realizedNotes = originalNotesText ?? "" - notesText = realizedNotes - remainingNotesCount = maxNotesCount - realizedNotes.count } func saveButtonTapped() { @@ -302,6 +312,12 @@ struct EditInfoView: View { log.debug("no changes - nothing to save") } + close() + } + + func close() { + log.trace("close()") + presentationMode.wrappedValue.dismiss() } } From 7d41653149b81c74346a45a7a8776046be8c4193 Mon Sep 17 00:00:00 2001 From: Robbie Hanson <304604+robbiehanson@users.noreply.github.com> Date: Mon, 29 Jul 2024 17:32:47 -0500 Subject: [PATCH 12/13] (ios) Adding "Send payment" button to Contact details view --- .../configuration/ConfigurationView.swift | 2 +- .../views/contacts/ContactsList.swift | 34 +++ .../views/contacts/ManageContact.swift | 247 ++++++++++++++++-- .../views/inspect/SummaryView.swift | 5 +- .../phoenix-ios/views/send/ValidateView.swift | 1 + .../views/style/TruncatableView.swift | 13 + 6 files changed, 273 insertions(+), 29 deletions(-) diff --git a/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift b/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift index ccc74fd05..8f5ae3230 100644 --- a/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift +++ b/phoenix-ios/phoenix-ios/views/configuration/ConfigurationView.swift @@ -421,7 +421,7 @@ fileprivate struct ConfigurationList: View { case .WalletCreationOptions : WalletCreationOptions() case .DisplayConfiguration : DisplayConfigurationView() case .PaymentOptions : PaymentOptionsView() - case .ContactsList : ContactsList() + case .ContactsList : ContactsList(popTo: popTo) case .Notifications : NotificationsView(location: .embedded) // Fees case .ChannelManagement : LiquidityPolicyView() diff --git a/phoenix-ios/phoenix-ios/views/contacts/ContactsList.swift b/phoenix-ios/phoenix-ios/views/contacts/ContactsList.swift index 179447316..438ec2a2b 100644 --- a/phoenix-ios/phoenix-ios/views/contacts/ContactsList.swift +++ b/phoenix-ios/phoenix-ios/views/contacts/ContactsList.swift @@ -10,6 +10,8 @@ fileprivate var log = LoggerFactory.shared.logger(filename, .warning) struct ContactsList: View { + let popTo: (PopToDestination) -> Void + @State var sortedContacts: [ContactInfo] = [] @State var offers: [String: [String]] = [:] @@ -20,6 +22,11 @@ struct ContactsList: View { @State var selectedItem: ContactInfo? = nil @State var pendingDelete: ContactInfo? = nil + @State var didAppear = false + @State var popToDestination: PopToDestination? = nil + + @Environment(\.presentationMode) var presentationMode: Binding + @EnvironmentObject var smartModalState: SmartModalState // -------------------------------------------------- @@ -55,6 +62,9 @@ struct ContactsList: View { func content() -> some View { list() + .onAppear() { + onAppear() + } .onReceive(Biz.business.contactsManager.contactsListPublisher()) { contactsListChanged($0) } @@ -134,6 +144,7 @@ struct ContactsList: View { if let selectedItem { ManageContact( location: .embedded, + popTo: popToWrapper, offer: nil, contact: selectedItem, contactUpdated: { _ in } @@ -141,6 +152,7 @@ struct ContactsList: View { } else if addItem { ManageContact( location: .embedded, + popTo: popToWrapper, offer: nil, contact: nil, contactUpdated: { _ in } @@ -193,6 +205,21 @@ struct ContactsList: View { ) } + // -------------------------------------------------- + // MARK: View Lifecycle + // -------------------------------------------------- + + func onAppear(){ + log.trace("onAppear()") + + if let destination = popToDestination { + log.debug("popToDestination: \(destination)") + + popToDestination = nil + presentationMode.wrappedValue.dismiss() + } + } + // -------------------------------------------------- // MARK: Notifications // -------------------------------------------------- @@ -241,6 +268,13 @@ struct ContactsList: View { // MARK: Actions // -------------------------------------------------- + func popToWrapper(_ destination: PopToDestination) { + log.trace("popToWrapper(\(destination))") + + popToDestination = destination + popTo(destination) + } + func deleteContact() { log.trace("deleteContact: \(pendingDelete?.name ?? "")") diff --git a/phoenix-ios/phoenix-ios/views/contacts/ManageContact.swift b/phoenix-ios/phoenix-ios/views/contacts/ManageContact.swift index 0678cb1e8..77ecc7c96 100644 --- a/phoenix-ios/phoenix-ios/views/contacts/ManageContact.swift +++ b/phoenix-ios/phoenix-ios/views/contacts/ManageContact.swift @@ -24,11 +24,12 @@ struct ManageContact: View { enum Location { case smartModal - case sheet + case sheet(closeAction: () -> Void) case embedded } let location: Location + let popTo: ((PopToDestination) -> Void)? let offer: Lightning_kmpOfferTypesOffer? let contact: ContactInfo? @@ -52,6 +53,15 @@ struct ManageContact: View { @State var pastedOfferIsInvalid: Bool = false @State var parsedOffer: Lightning_kmpOfferTypesOffer? = nil + enum FooterType: Int { + case expanded_standard = 1 + case expanded_squeezed = 2 + case compact_standard = 3 + case compact_squeezed = 4 + case accessible = 5 + } + @State var footerType: [DynamicTypeSize: FooterType] = [:] + @State var didAppear: Bool = false enum ActiveSheet { @@ -61,16 +71,24 @@ struct ManageContact: View { @State var activeSheet: ActiveSheet? = nil // For the footer buttons: [cancel, save] - enum MaxFooterButtonWidth: Preference {} - let maxFooterButtonWidthReader = GeometryPreferenceReader( - key: AppendValue.self, + enum FooterButtonWidth: Preference {} + let footerButtonWidthReader = GeometryPreferenceReader( + key: AppendValue.self, value: { [$0.size.width] } ) - @State var maxFooterButtonWidth: CGFloat? = nil + @State var footerButtonWidth: CGFloat? = nil + + enum FooterButtonHeight: Preference {} + let footerButtonHeightReader = GeometryPreferenceReader( + key: AppendValue.self, + value: { [$0.size.height] } + ) + @State var footerButtonHeight: CGFloat? = nil @StateObject var toast = Toast() @Environment(\.colorScheme) var colorScheme: ColorScheme + @Environment(\.dynamicTypeSize) var dynamicTypeSize: DynamicTypeSize @Environment(\.presentationMode) var presentationMode: Binding @EnvironmentObject var deviceInfo: DeviceInfo @@ -82,11 +100,13 @@ struct ManageContact: View { init( location: Location, + popTo: ((PopToDestination) -> Void)?, offer: Lightning_kmpOfferTypesOffer?, contact: ContactInfo?, contactUpdated: @escaping (ContactInfo?) -> Void ) { self.location = location + self.popTo = popTo self.offer = offer self.contact = contact self.contactUpdated = contactUpdated @@ -514,8 +534,8 @@ struct ManageContact: View { Image(systemName: "xmark") Text("Cancel") } - .frame(width: maxFooterButtonWidth) - .read(maxFooterButtonWidthReader) + .frame(width: footerButtonWidth) + .read(footerButtonWidthReader) } .buttonStyle(.bordered) .buttonBorderShape(.capsule) @@ -531,8 +551,8 @@ struct ManageContact: View { Image(systemName: "checkmark") Text("Save") } - .frame(width: maxFooterButtonWidth) - .read(maxFooterButtonWidthReader) + .frame(width: footerButtonWidth) + .read(footerButtonWidthReader) } .buttonStyle(.bordered) .buttonBorderShape(.capsule) @@ -541,31 +561,156 @@ struct ManageContact: View { } // .padding() - .assignMaxPreference(for: maxFooterButtonWidthReader.key, to: $maxFooterButtonWidth) + .assignMaxPreference(for: footerButtonWidthReader.key, to: $footerButtonWidth) } @ViewBuilder func footer_navStack() -> some View { if !isNewContact { - HStack(alignment: VerticalAlignment.centerTopLine, spacing: 0) { - Spacer(minLength: 0) - - Button { - showDeleteContactConfirmationDialog = true - } label: { - // Text("Delete contact") - Label("Delete contact", systemImage: "trash.fill") - .foregroundColor(.appNegative) - } - .disabled(isSaving) - - Spacer(minLength: 0) - } // - .padding(.bottom) + let type = footerType[dynamicTypeSize] ?? FooterType.expanded_standard + switch type { + case .expanded_standard: + footer_navStack_standard(compact: false) + case .expanded_squeezed: + footer_navStack_squeezed(compact: false) + case .compact_standard: + footer_navStack_standard(compact: true) + case .compact_squeezed: + footer_navStack_squeezed(compact: true) + case .accessible: + footer_navStack_accessible() + } } } + @ViewBuilder + func footer_navStack_standard(compact: Bool) -> some View { + + // We're making both buttons the same size. + // + // --------------------------------- + // Delete Contact | Send Payment + // --------------------------------- + // ^ ^ < same size + + let type: FooterType = compact ? FooterType.compact_standard : FooterType.expanded_standard + + HStack(alignment: VerticalAlignment.centerTopLine, spacing: 10) { + + TruncatableView(fixedHorizontal: true, fixedVertical: true) { + footer_button_deleteContact(compact: compact, lineLimit: 1) + } wasTruncated: { + footerTruncationDetected(type, "delete") + } + .frame(minWidth: footerButtonWidth, alignment: Alignment.trailing) + .read(footerButtonWidthReader) + .read(footerButtonHeightReader) + + if let footerButtonHeight { + Divider().frame(height: footerButtonHeight) + } + + TruncatableView(fixedHorizontal: true, fixedVertical: true) { + footer_button_sendPayment(compact: compact, lineLimit: 1) + } wasTruncated: { + footerTruncationDetected(type, "pay") + } + .frame(minWidth: footerButtonWidth, alignment: Alignment.leading) + .read(footerButtonWidthReader) + .read(footerButtonHeightReader) + + } // + .padding([.leading, .trailing, .bottom]) + .assignMaxPreference(for: footerButtonWidthReader.key, to: $footerButtonWidth) + .assignMaxPreference(for: footerButtonHeightReader.key, to: $footerButtonHeight) + } + + @ViewBuilder + func footer_navStack_squeezed(compact: Bool) -> some View { + + // There's not enough space to make both buttons the same size. + // So we're just trying to put them on one line. + // + // ------------------------------- + // Delete Contact | Send Payment + // ------------------------------- + // ^ ^ < NOT the same size + + let type: FooterType = compact ? FooterType.compact_squeezed : FooterType.expanded_squeezed + + HStack(alignment: VerticalAlignment.centerTopLine, spacing: 10) { + + TruncatableView(fixedHorizontal: true, fixedVertical: true) { + footer_button_deleteContact(compact: compact, lineLimit: 1) + } wasTruncated: { + footerTruncationDetected(type, "delete") + } + .read(footerButtonHeightReader) + + if let footerButtonHeight { + Divider().frame(height: footerButtonHeight) + } + + TruncatableView(fixedHorizontal: true, fixedVertical: true) { + footer_button_sendPayment(compact: compact, lineLimit: 1) + } wasTruncated: { + footerTruncationDetected(type, "pay") + } + .read(footerButtonHeightReader) + + } // + .padding([.leading, .trailing, .bottom]) + .assignMaxPreference(for: footerButtonHeightReader.key, to: $footerButtonHeight) + } + + @ViewBuilder + func footer_navStack_accessible() -> some View { + + // There's a large font being used, and possibly a small screen too. + // Horizontal space is so tight that we can't get the 3 buttons on a single line. + // + // So we're going to put them on multiple lines. + // + // -------------- + // Delete contact + // Send payment + // -------------- + + VStack(alignment: HorizontalAlignment.center, spacing: 16) { + footer_button_deleteContact(compact: true, lineLimit: nil) + footer_button_sendPayment(compact: true, lineLimit: nil) + } + .padding(.horizontal, 4) // allow content to be closer to edges + .padding(.bottom) + } + + @ViewBuilder + func footer_button_deleteContact(compact: Bool, lineLimit: Int?) -> some View { + + Button { + deleteButtonTapped() + } label: { + Label(compact ? "Delete" : "Delete contact", systemImage: "trash.fill") + .foregroundColor(.appNegative) + .lineLimit(lineLimit) + } + .disabled(isSaving) + } + + @ViewBuilder + func footer_button_sendPayment(compact: Bool, lineLimit: Int?) -> some View { + + Button { + payButtonTapped() + } label: { + Label(compact ? "Pay" : "Send payment", systemImage: "paperplane.fill") + .foregroundColor(.appPositive) + .lineLimit(lineLimit) + } + .disabled(hasChanges || isSaving) + } + // -------------------------------------------------- // MARK: View Helpers // -------------------------------------------------- @@ -740,6 +885,31 @@ struct ManageContact: View { // MARK: Actions // -------------------------------------------------- + func footerTruncationDetected(_ type: FooterType, _ identifier: String) { + + switch type { + case .expanded_standard: + log.debug("footerTruncationDetected: expanded_standard (\(identifier))") + footerType[dynamicTypeSize] = .expanded_squeezed + + case .expanded_squeezed: + log.debug("footerTruncationDetected: expanded_squeezed (\(identifier))") + footerType[dynamicTypeSize] = .compact_standard + + case .compact_standard: + log.debug("footerTruncationDetected: compact_standard (\(identifier))") + footerType[dynamicTypeSize] = .compact_squeezed + + case .compact_squeezed: + log.debug("footerTruncationDetected: compact_squeezed (\(identifier))") + footerType[dynamicTypeSize] = .accessible + + case .accessible: + log.debug("footerTruncationDetected: accessible (\(identifier))") + break + } + } + func selectImageOptionSelected() { log.trace("selectImageOptionSelected()") @@ -790,8 +960,33 @@ struct ManageContact: View { } } + func deleteButtonTapped() { + log.trace("deleteButtonTapped()") + + showDeleteContactConfirmationDialog = true + } + + func payButtonTapped() { + log.trace("payButtonTapped()") + + if let contact, let offer = contact.mostRelevantOffer { + let offerString = offer.encode() + AppDelegate.get().externalLightningUrlPublisher.send(offerString) + + if let popTo { + popTo(.ConfigurationView(followedBy: nil)) + } + + if case .sheet(let closeAction) = location { + closeAction() + } else { + close() + } + } + } + func cancelButtonTapped() { - log.trace("cancelButtonTapped") + log.trace("cancelButtonTapped()") if hasChanges && canSave { showDiscardChangesConfirmationDialog = true diff --git a/phoenix-ios/phoenix-ios/views/inspect/SummaryView.swift b/phoenix-ios/phoenix-ios/views/inspect/SummaryView.swift index ec97ac9e3..14101bef9 100644 --- a/phoenix-ios/phoenix-ios/views/inspect/SummaryView.swift +++ b/phoenix-ios/phoenix-ios/views/inspect/SummaryView.swift @@ -859,6 +859,7 @@ struct SummaryView: View { case .ContactView(let contact): ManageContact( location: manageContactLocation(), + popTo: nil, offer: nil, contact: contact, contactUpdated: { _ in } @@ -920,8 +921,8 @@ struct SummaryView: View { func manageContactLocation() -> ManageContact.Location { switch location { - case .sheet(_): - return ManageContact.Location.sheet + case .sheet(let closeAction): + return ManageContact.Location.sheet(closeAction: closeAction) case .embedded(_): return ManageContact.Location.embedded } diff --git a/phoenix-ios/phoenix-ios/views/send/ValidateView.swift b/phoenix-ios/phoenix-ios/views/send/ValidateView.swift index bbb483557..82a8de9db 100644 --- a/phoenix-ios/phoenix-ios/views/send/ValidateView.swift +++ b/phoenix-ios/phoenix-ios/views/send/ValidateView.swift @@ -1478,6 +1478,7 @@ struct ValidateView: View { smartModalState.display(dismissable: false) { ManageContact( location: .smartModal, + popTo: nil, offer: offer, contact: contact, contactUpdated: contactUpdated diff --git a/phoenix-ios/phoenix-ios/views/style/TruncatableView.swift b/phoenix-ios/phoenix-ios/views/style/TruncatableView.swift index 02c902f31..91ba71ec7 100644 --- a/phoenix-ios/phoenix-ios/views/style/TruncatableView.swift +++ b/phoenix-ios/phoenix-ios/views/style/TruncatableView.swift @@ -4,6 +4,13 @@ import SwiftUI +fileprivate let filename = "TruncatableView" +#if DEBUG && false +fileprivate var log = LoggerFactory.shared.logger(filename, .trace) +#else +fileprivate var log = LoggerFactory.shared.logger(filename, .warning) +#endif + struct TruncatableView: View { let fixedHorizontal: Bool @@ -52,6 +59,12 @@ struct TruncatableView: View { return } if rSize.width < iSize.width || rSize.height < iSize.height { + log.debug( + """ + rSize.width(\(rSize.width)) < iSize.width(\(iSize.width)) || \ + rSize.height(\(rSize.height)) < iSize.height(\(iSize.height)) + """ + ) wasTruncated() } } From bffc585a4042f84da856c57c28aac584bc053676 Mon Sep 17 00:00:00 2001 From: Robbie Hanson <304604+robbiehanson@users.noreply.github.com> Date: Tue, 30 Jul 2024 11:42:22 -0500 Subject: [PATCH 13/13] Bug fix: the contactsMap was updating at a later time than the contactsList, which lead to out-of-sync issues in the UI in certain contexts --- .../phoenix-ios.xcodeproj/project.pbxproj | 6 ++ .../kotlin/KotlinExtensions+Manager.swift | 92 +++++++++++++++++++ .../kotlin/KotlinExtensions+Other.swift | 79 ---------------- .../views/inspect/SummaryView.swift | 20 ++-- .../managers/ContactsManager.kt | 45 +++++---- 5 files changed, 134 insertions(+), 108 deletions(-) create mode 100644 phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Manager.swift diff --git a/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj b/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj index 32166b321..400291688 100644 --- a/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj +++ b/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj @@ -144,6 +144,8 @@ DC4CF3CE2BE96C36003A957F /* DisablePinView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4CF3CD2BE96C36003A957F /* DisablePinView.swift */; }; DC4CF3D02BEA8C13003A957F /* EditPinView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC4CF3CF2BEA8C13003A957F /* EditPinView.swift */; }; DC5567452C2F1A6900008E11 /* ContactsList.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5567442C2F1A6900008E11 /* ContactsList.swift */; }; + DC5631C72C5944CF00DCB5BF /* KotlinExtensions+Manager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5631C62C5944CF00DCB5BF /* KotlinExtensions+Manager.swift */; }; + DC5631C82C59466000DCB5BF /* KotlinExtensions+Manager.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5631C62C5944CF00DCB5BF /* KotlinExtensions+Manager.swift */; }; DC59377127516297003B4B53 /* Sequence+Sum.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC59377027516296003B4B53 /* Sequence+Sum.swift */; }; DC5A935329846044004F19FD /* FileHandle+Async.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5A935229846043004F19FD /* FileHandle+Async.swift */; }; DC5CA4ED28F83C3B0048A737 /* DrainWalletView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC5CA4EC28F83C3B0048A737 /* DrainWalletView.swift */; }; @@ -551,6 +553,7 @@ DC4CF3CD2BE96C36003A957F /* DisablePinView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisablePinView.swift; sourceTree = ""; }; DC4CF3CF2BEA8C13003A957F /* EditPinView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditPinView.swift; sourceTree = ""; }; DC5567442C2F1A6900008E11 /* ContactsList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsList.swift; sourceTree = ""; }; + DC5631C62C5944CF00DCB5BF /* KotlinExtensions+Manager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "KotlinExtensions+Manager.swift"; sourceTree = ""; }; DC59377027516296003B4B53 /* Sequence+Sum.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sequence+Sum.swift"; sourceTree = ""; }; DC5A935229846043004F19FD /* FileHandle+Async.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileHandle+Async.swift"; sourceTree = ""; }; DC5CA4EC28F83C3B0048A737 /* DrainWalletView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DrainWalletView.swift; sourceTree = ""; }; @@ -1045,6 +1048,7 @@ DC46BAF126CACCF700E760A6 /* KotlinExtensions+Conversion.swift */, DC16965E27FE0FAC003DE1DD /* KotlinExtensions+Currency.swift */, DC0D2EA62939273B00284608 /* KotlinExtensions+Payments.swift */, + DC5631C62C5944CF00DCB5BF /* KotlinExtensions+Manager.swift */, DC46BAED26CACCF700E760A6 /* KotlinExtensions+Other.swift */, DC49FE9A2AC49CB500D8D2E2 /* KotlinExtensions+Lightning.swift */, DC49FE982AC49C6300D8D2E2 /* KotlinExtensions+Bitcoin.swift */, @@ -1907,6 +1911,7 @@ DC18C418256FE22300A2D083 /* Prefs.swift in Sources */, DCAEF8F5276131A600015993 /* CheckboxToggleStyle.swift in Sources */, DC641C712820889C00862DCD /* GroupPrefs.swift in Sources */, + DC5631C72C5944CF00DCB5BF /* KotlinExtensions+Manager.swift in Sources */, DC63BDF429AE44380067A361 /* NotificationsManager.swift in Sources */, DC118BFA27B44F840080BBAC /* TipSliderSheet.swift in Sources */, DC2F431827B698E20006FCC4 /* ShareOptionsSheet.swift in Sources */, @@ -2107,6 +2112,7 @@ DCA6DEC92829C3180073C658 /* GenericPasswordConvertible.swift in Sources */, DCCFE6A72B630836002FFF11 /* KotlinLogger.swift in Sources */, DC641C78282171EA00862DCD /* KotlinAssociatedObject.swift in Sources */, + DC5631C82C59466000DCB5BF /* KotlinExtensions+Manager.swift in Sources */, DCCFE6B32B680DF5002FFF11 /* LoggerFactory.swift in Sources */, DCA6DECA2829C31B0073C658 /* KeyStoreError.swift in Sources */, DC49FE9D2AC49E0800D8D2E2 /* KotlinExtensions+Bitcoin.swift in Sources */, diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Manager.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Manager.swift new file mode 100644 index 000000000..b2c4281c0 --- /dev/null +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Manager.swift @@ -0,0 +1,92 @@ +import Foundation +import PhoenixShared +import Combine + +extension BalanceManager { + + func swapInWalletValue() -> Lightning_kmpWalletState.WalletWithConfirmations { + if let value = self.swapInWallet.value as? Lightning_kmpWalletState.WalletWithConfirmations { + return value + } else { + return Lightning_kmpWalletState.WalletWithConfirmations.empty() + } + } +} + +extension ConnectionsManager { + + var currentValue: Connections { + return connections.value as! Connections + } + + func asyncStream() -> AsyncStream { + + return AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in + + let swiftFlow = SwiftFlow(origin: self.connections) + + let watcher = swiftFlow.watch {(connections: Connections?) in + if let connections { + continuation.yield(connections) + } + } + + continuation.onTermination = { _ in + DispatchQueue.main.async { + // I'm not sure what thread this will be called from. + // And I've witnessed crashes when invoking `watcher.close()` from a non-main thread. + watcher.close() + } + } + } + } +} + +extension ContactsManager { + + func contactsListCurrentValue() -> [ContactInfo] { + return contactsList.value as? [ContactInfo] ?? [] + } + + func contactsMapCurrentValue() -> [Lightning_kmpUUID: ContactInfo] { + return contactsMap.value as? [Lightning_kmpUUID: ContactInfo] ?? [:] + } +} + +extension PeerManager { + + func peerStateValue() -> Lightning_kmpPeer? { + return peerState.value as? Lightning_kmpPeer + } + + func channelsFlowValue() -> [Bitcoin_kmpByteVector32: LocalChannelInfo] { + if let value = self.channelsFlow.value as? [Bitcoin_kmpByteVector32: LocalChannelInfo] { + return value + } else { + return [:] + } + } + + func channelsValue() -> [LocalChannelInfo] { + return channelsFlowValue().map { $1 } + } + + func finalWalletValue() -> Lightning_kmpWalletState.WalletWithConfirmations { + if let value = self.finalWallet.value as? Lightning_kmpWalletState.WalletWithConfirmations { + return value + } else { + return Lightning_kmpWalletState.WalletWithConfirmations.empty() + } + } +} + +extension WalletManager { + + func keyManagerValue() -> Lightning_kmpLocalKeyManager? { + if let value = keyManager.value as? Lightning_kmpLocalKeyManager { + return value + } else { + return nil + } + } +} diff --git a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Other.swift b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Other.swift index f90c1c8a2..cb650f34f 100644 --- a/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Other.swift +++ b/phoenix-ios/phoenix-ios/kotlin/KotlinExtensions+Other.swift @@ -2,56 +2,6 @@ import Foundation import PhoenixShared import Combine - -extension PeerManager { - - func peerStateValue() -> Lightning_kmpPeer? { - return peerState.value as? Lightning_kmpPeer - } - - func channelsFlowValue() -> [Bitcoin_kmpByteVector32: LocalChannelInfo] { - if let value = self.channelsFlow.value as? [Bitcoin_kmpByteVector32: LocalChannelInfo] { - return value - } else { - return [:] - } - } - - func channelsValue() -> [LocalChannelInfo] { - return channelsFlowValue().map { $1 } - } - - func finalWalletValue() -> Lightning_kmpWalletState.WalletWithConfirmations { - if let value = self.finalWallet.value as? Lightning_kmpWalletState.WalletWithConfirmations { - return value - } else { - return Lightning_kmpWalletState.WalletWithConfirmations.empty() - } - } -} - -extension BalanceManager { - - func swapInWalletValue() -> Lightning_kmpWalletState.WalletWithConfirmations { - if let value = self.swapInWallet.value as? Lightning_kmpWalletState.WalletWithConfirmations { - return value - } else { - return Lightning_kmpWalletState.WalletWithConfirmations.empty() - } - } -} - -extension WalletManager { - - func keyManagerValue() -> Lightning_kmpLocalKeyManager? { - if let value = keyManager.value as? Lightning_kmpLocalKeyManager { - return value - } else { - return nil - } - } -} - extension WalletBalance { var confirmed: Bitcoin_kmpSatoshi { @@ -66,35 +16,6 @@ extension PhoenixShared.Notification { } } -extension ConnectionsManager { - - var currentValue: Connections { - return connections.value as! Connections - } - - func asyncStream() -> AsyncStream { - - return AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in - - let swiftFlow = SwiftFlow(origin: self.connections) - - let watcher = swiftFlow.watch {(connections: Connections?) in - if let connections { - continuation.yield(connections) - } - } - - continuation.onTermination = { _ in - DispatchQueue.main.async { - // I'm not sure what thread this will be called from. - // And I've witnessed crashes when invoking `watcher.close()` from a non-main thread. - watcher.close() - } - } - } - } -} - extension Connections { func oneOrMoreEstablishing() -> Bool { diff --git a/phoenix-ios/phoenix-ios/views/inspect/SummaryView.swift b/phoenix-ios/phoenix-ios/views/inspect/SummaryView.swift index 14101bef9..49a514798 100644 --- a/phoenix-ios/phoenix-ios/views/inspect/SummaryView.swift +++ b/phoenix-ios/phoenix-ios/views/inspect/SummaryView.swift @@ -1018,32 +1018,31 @@ struct SummaryView: View { func onAppear() { log.trace("onAppear()") - let business = Biz.business - if !didAppear { didAppear = true // First time displaying the SummaryView (coming from HomeView) if paymentInfoIsStale { - // We either don't have the full payment information (missing metadata info), // or the payment information is possibly stale, and needs to be refreshed. if let row = paymentInfo.toOrderRow() { - business.paymentsManager.fetcher.getPayment(row: row, options: fetchOptions) { (result, _) in - - if let result = result { + Biz.business.paymentsManager.fetcher.getPayment(row: row, options: fetchOptions) { + (result: WalletPaymentInfo?, _) in + + if let result { paymentInfo = result } } } else { - business.paymentsManager.getPayment(id: paymentInfo.id(), options: fetchOptions) { (result, _) in + Biz.business.paymentsManager.getPayment(id: paymentInfo.id(), options: fetchOptions) { + (result: WalletPaymentInfo?, _) in - if let result = result { + if let result { paymentInfo = result } } @@ -1056,9 +1055,10 @@ struct SummaryView: View { // The payment metadata may have changed (e.g. description/notes modified). // So we need to refresh the payment info. - business.paymentsManager.getPayment(id: paymentInfo.id(), options: fetchOptions) { (result, _) in + Biz.business.paymentsManager.getPayment(id: paymentInfo.id(), options: fetchOptions) { + (result: WalletPaymentInfo?, _) in - if let result = result { + if let result { paymentInfo = result } } diff --git a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/ContactsManager.kt b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/ContactsManager.kt index 95017e70c..cac895c8d 100644 --- a/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/ContactsManager.kt +++ b/phoenix-shared/src/commonMain/kotlin/fr.acinq.phoenix/managers/ContactsManager.kt @@ -56,32 +56,39 @@ class ContactsManager( private val _contactsList = MutableStateFlow>(emptyList()) val contactsList = _contactsList.asStateFlow() - val contactsMap = _contactsList.map { list -> - list.associateBy { it.id } - }.stateIn(scope = this, started = SharingStarted.Eagerly, initialValue = emptyMap()) - - val offerMap = _contactsList.map { list -> - list.flatMap { contact -> - contact.offers.map { offer -> - offer to contact.id - } - }.toMap() - }.stateIn(scope = this, started = SharingStarted.Eagerly, initialValue = emptyMap()) + private val _contactsMap = MutableStateFlow>(emptyMap()) + val contactsMap = _contactsMap.asStateFlow() - val publicKeyMap = _contactsList.map { list -> - list.flatMap { contact -> - contact.publicKeys.map { pubKey -> - pubKey to contact.id - } - }.toMap() - }.stateIn(scope = this, started = SharingStarted.Eagerly, initialValue = emptyMap()) + private val _offerMap = MutableStateFlow>(emptyMap()) + val offerMap = _offerMap.asStateFlow() + + private val _publicKeyMap = MutableStateFlow>(emptyMap()) + val publicKeyMap = _publicKeyMap.asStateFlow() val contactsWithOfferList = _contactsList.map { contacts -> contacts.filter { it.offers.isNotEmpty() } } init { - launch { appDb.monitorContacts().collect { _contactsList.value = it } } + launch { + appDb.monitorContacts().collect { list -> + val newMap = list.associateBy { it.id } + val newOfferMap = list.flatMap { contact -> + contact.offers.map { offer -> + offer to contact.id + } + }.toMap() + val newPublicKeyMap = list.flatMap { contact -> + contact.publicKeys.map { pubKey -> + pubKey to contact.id + } + }.toMap() + _contactsList.value = list + _contactsMap.value = newMap + _offerMap.value = newOfferMap + _publicKeyMap.value = newPublicKeyMap + } + } } suspend fun getContactForOffer(offer: OfferTypes.Offer): ContactInfo? {