diff --git a/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj b/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj index f3a7231e1..819baa257 100644 --- a/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj +++ b/phoenix-ios/phoenix-ios.xcodeproj/project.pbxproj @@ -212,6 +212,8 @@ DC949E6A2B45B1EC00E80BB5 /* LiquidityAdsHelp.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC949E692B45B1EC00E80BB5 /* LiquidityAdsHelp.swift */; }; DC98D3962AF170AC005BD177 /* PaymentWarningPopover.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC98D3952AF170AC005BD177 /* PaymentWarningPopover.swift */; }; DC98D3982AF2AE41005BD177 /* ReceiveView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC98D3972AF2AE41005BD177 /* ReceiveView.swift */; }; + DC9933322CC03D7500EB3100 /* ContactsListSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC9933312CC03D7500EB3100 /* ContactsListSheet.swift */; }; + DC9933342CC0426300EB3100 /* AddContactOptionsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC9933332CC0426300EB3100 /* AddContactOptionsSheet.swift */; }; DC99E90925B78FA800FB20F7 /* EnabledSecurity.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC99E90825B78FA800FB20F7 /* EnabledSecurity.swift */; }; DC99E94025BA141000FB20F7 /* LocalWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC99E93F25BA141000FB20F7 /* LocalWebView.swift */; }; DC99E94D25BA258C00FB20F7 /* about.html in Resources */ = {isa = PBXBuildFile; fileRef = DC99E94825BA258C00FB20F7 /* about.html */; }; @@ -617,6 +619,8 @@ DC949E692B45B1EC00E80BB5 /* LiquidityAdsHelp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiquidityAdsHelp.swift; sourceTree = ""; }; DC98D3952AF170AC005BD177 /* PaymentWarningPopover.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaymentWarningPopover.swift; sourceTree = ""; }; DC98D3972AF2AE41005BD177 /* ReceiveView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReceiveView.swift; sourceTree = ""; }; + DC9933312CC03D7500EB3100 /* ContactsListSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsListSheet.swift; sourceTree = ""; }; + DC9933332CC0426300EB3100 /* AddContactOptionsSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AddContactOptionsSheet.swift; sourceTree = ""; }; DC99E90825B78FA800FB20F7 /* EnabledSecurity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnabledSecurity.swift; sourceTree = ""; }; DC99E93F25BA141000FB20F7 /* LocalWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LocalWebView.swift; sourceTree = ""; }; DC99E94925BA258C00FB20F7 /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.html; name = Base; path = Base.lproj/about.html; sourceTree = ""; }; @@ -1049,7 +1053,9 @@ children = ( DC3345CF2C2B4C1200EDD2D4 /* ManageContact.swift */, DC5567442C2F1A6900008E11 /* ContactsList.swift */, + DC9933312CC03D7500EB3100 /* ContactsListSheet.swift */, DC6F04262C3895E300627B4F /* ContactPhoto.swift */, + DC9933332CC0426300EB3100 /* AddContactOptionsSheet.swift */, ); path = contacts; sourceTree = ""; @@ -1838,6 +1844,7 @@ DC6F19BF2C46FB0F004EC469 /* NSItemProvider+Async.swift in Sources */, DCCFE6C22B7140FA002FFF11 /* LoggerFactory+Foreground.swift in Sources */, 7555FF7F242A565900829871 /* AppDelegate.swift in Sources */, + DC9933342CC0426300EB3100 /* AddContactOptionsSheet.swift in Sources */, DC74174B270F332700F7E3E3 /* KotlinTypes.swift in Sources */, DC142135261E72320075857A /* AboutHTML.swift in Sources */, DC33C5632A7C15D40053D785 /* MainView_BigPrimary.swift in Sources */, @@ -2043,6 +2050,7 @@ DC5CA4ED28F83C3B0048A737 /* DrainWalletView.swift in Sources */, DC5E288E2C62C3DF0037B3D3 /* NavigationStackDestination.swift in Sources */, DC784A112B31EA180018DC4A /* LiquidityAdsView.swift in Sources */, + DC9933322CC03D7500EB3100 /* ContactsListSheet.swift in Sources */, DC6D26E329E76557006A7814 /* AnimatedClock.swift in Sources */, DC72CEF52C99DCEB00C810A8 /* LnurlFlowErrorNotice.swift in Sources */, DC5631C52C541E5C00DCB5BF /* Experimental.swift in Sources */, diff --git a/phoenix-ios/phoenix-ios/Localizable.xcstrings b/phoenix-ios/phoenix-ios/Localizable.xcstrings index bcf7af2be..b26342986 100644 --- a/phoenix-ios/phoenix-ios/Localizable.xcstrings +++ b/phoenix-ios/phoenix-ios/Localizable.xcstrings @@ -5590,6 +5590,9 @@ } } } + }, + "Add to existing contact" : { + }, "adding to existing channel" : { "comment" : "Transaction Info: Explanation", @@ -13474,6 +13477,9 @@ } } } + }, + "Create new contact" : { + }, "Create new wallet" : { "localizations" : { @@ -34729,7 +34735,6 @@ } }, "Search" : { - "extractionState" : "stale", "localizations" : { "ar" : { "stringUnit" : { diff --git a/phoenix-ios/phoenix-ios/views/contacts/AddContactOptionsSheet.swift b/phoenix-ios/phoenix-ios/views/contacts/AddContactOptionsSheet.swift new file mode 100644 index 000000000..38f584201 --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/contacts/AddContactOptionsSheet.swift @@ -0,0 +1,79 @@ +import SwiftUI + +struct AddContactOptionsSheet: View { + + let createNewContact: () -> Void + let addToExistingContact: () -> Void + + @EnvironmentObject var smartModalState: SmartModalState + + @ViewBuilder + var body: some View { + + VStack(alignment: HorizontalAlignment.leading, spacing: 10) { + title() + createNewContactRow() + addToExistingContactRow() + } + .padding(.all) + } + + @ViewBuilder + func title() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 0) { + Spacer(minLength: 4) + Text("Add contact") + Spacer(minLength: 4) + } + .font(.title2) + } + + @ViewBuilder + func createNewContactRow() -> some View { + + Button { + smartModalState.close { + createNewContact() + } + } label: { + HStack(alignment: VerticalAlignment.center, spacing: 4) { + Text("Create new contact") + Spacer() + } + .padding([.top, .bottom], 8) + .padding([.leading, .trailing], 16) + .contentShape(Rectangle()) // make Spacer area tappable + } + .buttonStyle( + ScaleButtonStyle( + cornerRadius: 16, + borderStroke: Color.appAccent + ) + ) + } + + @ViewBuilder + func addToExistingContactRow() -> some View { + + Button { + smartModalState.close { + addToExistingContact() + } + } label: { + HStack(alignment: VerticalAlignment.center, spacing: 4) { + Text("Add to existing contact") + Spacer() + } + .padding([.top, .bottom], 8) + .padding([.leading, .trailing], 16) + .contentShape(Rectangle()) // make Spacer area tappable + } + .buttonStyle( + ScaleButtonStyle( + cornerRadius: 16, + borderStroke: Color.appAccent + ) + ) + } +} 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..f3acb583d --- /dev/null +++ b/phoenix-ios/phoenix-ios/views/contacts/ContactsListSheet.swift @@ -0,0 +1,311 @@ +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 search_offers: [Lightning_kmpUUID: [String]] = [:] + @State var search_addresses: [Lightning_kmpUUID: [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 { + zeroMatchesRow() + } else if hasZeroContacts { + zeroContactsRow() + } + } // + .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 zeroMatchesRow() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 8) { + Image(systemName: "person.crop.circle.badge.questionmark") + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + Text("No matches for search") + Spacer() + } + .foregroundStyle(.secondary) + .padding(.all, 4) + } + + @ViewBuilder + func zeroContactsRow() -> some View { + + HStack(alignment: VerticalAlignment.center, spacing: 8) { + Image(systemName: "person.crop.circle.fill") + .resizable() + .scaledToFit() + .frame(width: 32, height: 32) + VStack(alignment: HorizontalAlignment.leading, spacing: 4) { + Text("No Contacts") + .font(.title3) + .foregroundColor(.primary) + Text("Add contacts for easy & quick payments") + .font(.subheadline) + .foregroundColor(.secondary) + } + Spacer() + } + .padding(.all, 4) + } + + // -------------------------------------------------- + // MARK: View Helpers + // -------------------------------------------------- + + var visibleContacts: [ContactInfo] { + return filteredContacts ?? sortedContacts + } + + var hasZeroMatchesForSearch: Bool { + if sortedContacts.isEmpty { + // User has zero contacts. + // This is different from zero search results. + return false + } else if let filteredContacts { + return filteredContacts.isEmpty + } else { + // Not searching + return false + } + } + + var hasZeroContacts: Bool { + return sortedContacts.isEmpty + } + + // -------------------------------------------------- + // MARK: Notifications + // -------------------------------------------------- + + func contactsListChanged(_ updatedList: [ContactInfo]) { + log.trace("contactsListChanged()") + + sortedContacts = updatedList + + do { + var offers: [Lightning_kmpUUID: [String]] = [:] + for contact in sortedContacts { + let key: Lightning_kmpUUID = contact.id + let values: [String] = contact.offers.map { + $0.offer.encode().lowercased() + } + offers[key] = values + } + + search_offers = offers + } + do { + var addresses: [Lightning_kmpUUID: [String]] = [:] + for contact in sortedContacts { + let key: Lightning_kmpUUID = contact.id + let values: [String] = contact.addresses.map { + $0.address.trimmingCharacters(in: .whitespacesAndNewlines) + } + addresses[key] = values + } + + search_addresses = addresses + } + } + + func searchTextChanged() { + log.trace("searchTextChanged: \(searchText)") + + guard !searchText.isEmpty else { + filteredContacts = nil + return + } + + let searchtext = searchText.lowercased() + filteredContacts = sortedContacts.filter { (contact: ContactInfo) in + + // `localizedCaseInsensitiveContains` doesn't properly ignore diacritic marks. + // For example: search text of "belen" doesn't match name "Belén". + // + // `localizedStandardContains`: + // > This is the most appropriate method for doing user-level string searches, + // > similar to how searches are done generally in the system. The search is + // > locale-aware, case and diacritic insensitive. The exact list of search + // > options applied may change over time. + + if contact.name.localizedStandardContains(searchtext) { + return true + } + + if let offers = search_offers[contact.id] { + if offers.contains(searchtext) { + return true + } + } + + if let addresses = search_addresses[contact.id] { + if addresses.contains(where: { $0.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 index eb969a550..fec90974c 100644 --- a/phoenix-ios/phoenix-ios/views/contacts/ManageContact.swift +++ b/phoenix-ios/phoenix-ios/views/contacts/ManageContact.swift @@ -83,7 +83,7 @@ struct ManageContact: View { @State var showDeleteContactConfirmationDialog: Bool = false @State var offers: [OfferRow] - @State var offers_hasChanges: Bool = false + @State var offers_hasChanges: Bool @State var editOffer_index: Int? = nil @State var editOffer_label: String = "" @@ -91,7 +91,7 @@ struct ManageContact: View { @State var editOffer_invalidReason: InvalidReason? = nil @State var addresses: [AddressRow] - @State var addresses_hasChanges: Bool = false + @State var addresses_hasChanges: Bool @State var editAddress_index: Int? = nil @State var editAddress_label: String = "" @@ -181,6 +181,7 @@ struct ManageContact: View { } self._offers = State(initialValue: rows) + self._offers_hasChanges = State(initialValue: (contact != nil && offer != nil)) } do { var set = Set() @@ -201,6 +202,7 @@ struct ManageContact: View { } self._addresses = State(initialValue: rows) + self._addresses_hasChanges = State(initialValue: (contact != nil && address != nil)) } } diff --git a/phoenix-ios/phoenix-ios/views/send/PaymentDetails.swift b/phoenix-ios/phoenix-ios/views/send/PaymentDetails.swift index d4bf87362..af1acd0f8 100644 --- a/phoenix-ios/phoenix-ios/views/send/PaymentDetails.swift +++ b/phoenix-ios/phoenix-ios/views/send/PaymentDetails.swift @@ -268,7 +268,7 @@ struct PaymentDetails: View { Text(contact.name) } // .onTapGesture { - parent.showManageContactSheet() + parent.manageExistingContact() } } @@ -289,7 +289,7 @@ struct PaymentDetails: View { } } Button { - parent.showManageContactSheet() + parent.addContact() } label: { HStack(alignment: VerticalAlignment.firstTextBaseline, spacing: 2) { Image(systemName: "person") diff --git a/phoenix-ios/phoenix-ios/views/send/ValidateView.swift b/phoenix-ios/phoenix-ios/views/send/ValidateView.swift index 0202b56e8..eaa8c70ea 100644 --- a/phoenix-ios/phoenix-ios/views/send/ValidateView.swift +++ b/phoenix-ios/phoenix-ios/views/send/ValidateView.swift @@ -1595,8 +1595,34 @@ struct ValidateView: View { } } - func showManageContactSheet() { - log.trace("showManageContactSheet()") + func manageExistingContact() { + log.trace("manageExistingContact()") + + guard let contact else { + log.info("manageExistingContact(): ignoring: no existing contact") + return + } + + dismissKeyboardIfVisible() + smartModalState.display(dismissable: false) { + ManageContact( + location: .smartModal, + popTo: nil, + offer: nil, + address: nil, + contact: contact, + contactUpdated: contactUpdated + ) + } + } + + func addContact() { + log.trace("addContact()") + + guard contact == nil else { + log.info("addContact(): ignoring: contact already exists") + return + } let address = lightningAddress() @@ -1606,9 +1632,34 @@ struct ValidateView: View { } guard (address != nil) || (offer != nil) else { + log.info("addContact(): ignoring: missing address/offer") return } + let count: Int = Biz.business.contactsManager.contactsListCurrentValue().count + if count == 0 { + // User doesn't have any contacts. + // No choice but to create a new contact. + addContact_createNew(address, offer) + + } else { + + dismissKeyboardIfVisible() + smartModalState.display(dismissable: true) { + AddContactOptionsSheet( + createNewContact: { addContact_createNew(address, offer) }, + addToExistingContact: { addContact_selectExisting(address, offer) } + ) + } + } + } + + private func addContact_createNew( + _ address: String?, + _ offer: Lightning_kmpOfferTypesOffer? + ) { + log.trace("addContact_createNew()") + dismissKeyboardIfVisible() smartModalState.display(dismissable: false) { ManageContact( @@ -1616,7 +1667,44 @@ struct ValidateView: View { popTo: nil, offer: offer, address: address, - contact: contact, + contact: nil, + contactUpdated: contactUpdated + ) + } + } + + private func addContact_selectExisting( + _ address: String?, + _ offer: Lightning_kmpOfferTypesOffer? + ) { + log.trace("addContact_selectExisting()") + + dismissKeyboardIfVisible() + smartModalState.display(dismissable: true) { + ContactsListSheet(didSelectContact: { existingContact in + log.debug("didSelectContact") + smartModalState.onNextDidDisappear { + addContact_addToExisting(existingContact, address, offer) + } + }) + } + } + + private func addContact_addToExisting( + _ existingContact: ContactInfo, + _ address: String?, + _ offer: Lightning_kmpOfferTypesOffer? + ) { + log.trace("addContact_addToExisting()") + + dismissKeyboardIfVisible() + smartModalState.display(dismissable: false) { + ManageContact( + location: .smartModal, + popTo: nil, + offer: offer, + address: address, + contact: existingContact, contactUpdated: contactUpdated ) }