diff --git a/.swiftlint.yml b/.swiftlint.yml index 9ff9dc0c8..8c6bdea78 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -36,7 +36,7 @@ disabled_rules: - function_parameter_count - comment_spacing - unused_closure_parameter -# - unneeded_notification_center_removal + - nesting custom_rules: empty_line_after_guard_statement: included: ".*\\.swift" diff --git a/KNOWN_ISSUES.md b/KNOWN_ISSUES.md index 576976d90..e691b6d6a 100644 --- a/KNOWN_ISSUES.md +++ b/KNOWN_ISSUES.md @@ -14,7 +14,7 @@ It should only be used with dedicated test servers, test data - and test devices - subscription of spaces can't be turned on/off yet - the root of spaces-based accounts is not yet shown as hierarchic sidebar - support for sharing is widely untested and/or unavailable in the alpha -- inactivated state of spaces is not yet represented in the UI +- inactivated state of spaces is not yet represented in the UI (inactivated spaces are currently hidden instead) - Copy & Paste allows copying a folder into a subfolder of its own / itself, leading to an infinite cycle - handling of detached drives with user data in them (see OCVault.detachedDrives) - sync actions that are actually complete are not always cleared from the Status tab until a logout/login @@ -24,12 +24,13 @@ It should only be used with dedicated test servers, test data - and test devices - dragging an entire space on top of another starts a full copy of the space, which eventually fails halfway through ## SDK -- local storage consumed by spaces that are then deleted or inactivated is not reclaimed - pre-population of accounts using infinite PROPFIND is not supported # Evolution roadmap - collection views - support sidebars / hierarchies, including expanded state, with dynamic updates from data sources + - ItemListCell: replace manual composition of info line below name with SegmentView + - allows to show different content there, f.ex. Space and Folder in search - location picker replaces folder picker - supports picking diff --git a/ios-sdk b/ios-sdk index adea1a552..3a3d9ecdf 160000 --- a/ios-sdk +++ b/ios-sdk @@ -1 +1 @@ -Subproject commit adea1a55209069502d392b80019bfec908c5161e +Subproject commit 3a3d9ecdff0344e5f9954e08820b63cecd317a74 diff --git a/ownCloud.xcodeproj/project.pbxproj b/ownCloud.xcodeproj/project.pbxproj index 1c8a63896..ad1589ffd 100644 --- a/ownCloud.xcodeproj/project.pbxproj +++ b/ownCloud.xcodeproj/project.pbxproj @@ -287,6 +287,14 @@ DC24B29825BA2A34005783E2 /* Branding.m in Sources */ = {isa = PBXBuildFile; fileRef = DC24B27225B9DF31005783E2 /* Branding.m */; }; DC24B2AB25BA316D005783E2 /* Branding+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC24B2AA25BA316D005783E2 /* Branding+App.swift */; }; DC24B31D25BB6FC4005783E2 /* IssuesCardViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC24B31C25BB6FC4005783E2 /* IssuesCardViewController.swift */; }; + DC24E0EA28B36A81002E4F5B /* OCSearchSegment.h in Headers */ = {isa = PBXBuildFile; fileRef = DC24E0E828B36A81002E4F5B /* OCSearchSegment.h */; settings = {ATTRIBUTES = (Public, ); }; }; + DC24E0EB28B36A81002E4F5B /* OCSearchSegment.m in Sources */ = {isa = PBXBuildFile; fileRef = DC24E0E928B36A81002E4F5B /* OCSearchSegment.m */; }; + DC24E0F828B41694002E4F5B /* OCQueryCondition+SearchToken.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC24E0F728B41693002E4F5B /* OCQueryCondition+SearchToken.swift */; }; + DC24E10428B7BF4E002E4F5B /* CustomQuerySearchTokenizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC24E10328B7BF4E002E4F5B /* CustomQuerySearchTokenizer.swift */; }; + DC24E10728B7BFD6002E4F5B /* ItemSearchScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC24E10628B7BFD6002E4F5B /* ItemSearchScope.swift */; }; + DC24E10B28B7C185002E4F5B /* SingleFolderSearchScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC24E10A28B7C185002E4F5B /* SingleFolderSearchScope.swift */; }; + DC24E10D28B7C19F002E4F5B /* AccountSearchScope.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC24E10C28B7C19F002E4F5B /* AccountSearchScope.swift */; }; + DC24E10F28B7D2B9002E4F5B /* PopupButtonController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC24E10E28B7D2B9002E4F5B /* PopupButtonController.swift */; }; DC2565EE225F5A1900828AA5 /* UserNotifications.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DC2565E8225F5A1900828AA5 /* UserNotifications.framework */; }; DC26ADDE2550C0B20059680D /* MetadataDocumentationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC26ADDD2550C0B20059680D /* MetadataDocumentationTests.swift */; }; DC27A18E20CA9F66008ACB6C /* OCItem+FileProviderItem.m in Sources */ = {isa = PBXBuildFile; fileRef = DC27A18D20CA9F66008ACB6C /* OCItem+FileProviderItem.m */; }; @@ -296,6 +304,10 @@ DC27A1A820CC095C008ACB6C /* OCCore+FileProviderTools.m in Sources */ = {isa = PBXBuildFile; fileRef = DC27A1A720CC095C008ACB6C /* OCCore+FileProviderTools.m */; }; DC27A1E920CC56B0008ACB6C /* FileProviderExtensionThumbnailRequest.m in Sources */ = {isa = PBXBuildFile; fileRef = DC27A1E820CC56B0008ACB6C /* FileProviderExtensionThumbnailRequest.m */; }; DC29F09522976B9300F77349 /* LibrarySharesTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC29F09222976B9200F77349 /* LibrarySharesTableViewController.swift */; }; + DC2A128428D0722F0088A2B7 /* OCSavedSearch.h in Headers */ = {isa = PBXBuildFile; fileRef = DC2A127C28D06F060088A2B7 /* OCSavedSearch.h */; settings = {ATTRIBUTES = (Public, ); }; }; + DC2A128528D072350088A2B7 /* OCVault+SavedSearches.h in Headers */ = {isa = PBXBuildFile; fileRef = DC2A128028D0718B0088A2B7 /* OCVault+SavedSearches.h */; settings = {ATTRIBUTES = (Public, ); }; }; + DC2A128628D0725D0088A2B7 /* OCVault+SavedSearches.m in Sources */ = {isa = PBXBuildFile; fileRef = DC2A128128D0718B0088A2B7 /* OCVault+SavedSearches.m */; }; + DC2A128728D0725D0088A2B7 /* OCSavedSearch.m in Sources */ = {isa = PBXBuildFile; fileRef = DC2A127D28D06F060088A2B7 /* OCSavedSearch.m */; }; DC2FE2DA24C30586002AFDB3 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 593A821320C7D4C5000E2A90 /* Localizable.strings */; }; DC33939622E0747400DD3DA4 /* MakeAvailableOfflineAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC33939522E0747400DD3DA4 /* MakeAvailableOfflineAction.swift */; }; DC33939D22E076E300DD3DA4 /* MakeUnavailableOfflineAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC33939C22E076E300DD3DA4 /* MakeUnavailableOfflineAction.swift */; }; @@ -328,6 +340,9 @@ DC46F3D32845583D00038880 /* OCDrive+Interactions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC46F3D22845583D00038880 /* OCDrive+Interactions.swift */; }; DC46F3D5284563BD00038880 /* OCAction+Interactions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC46F3D4284563BD00038880 /* OCAction+Interactions.swift */; }; DC46F3D72845FCFA00038880 /* UIView+OCDataItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC46F3D62845FCFA00038880 /* UIView+OCDataItem.swift */; }; + DC46F68E28DAFCDD008280CA /* RoundCornerBackgroundView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC46F68D28DAFCDD008280CA /* RoundCornerBackgroundView.swift */; }; + DC46F69028DCA1B8008280CA /* SavedSearchCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC46F68F28DCA1B8008280CA /* SavedSearchCell.swift */; }; + DC46F69228DCB9C6008280CA /* OCSavedSearch+Interactions.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC46F69128DCB9C6008280CA /* OCSavedSearch+Interactions.swift */; }; DC49B55928365C5F00DAF13B /* OCVault+VFSManager.h in Headers */ = {isa = PBXBuildFile; fileRef = DC49B55728365C5F00DAF13B /* OCVault+VFSManager.h */; }; DC49B55A28365C5F00DAF13B /* OCVault+VFSManager.m in Sources */ = {isa = PBXBuildFile; fileRef = DC49B55828365C5F00DAF13B /* OCVault+VFSManager.m */; }; DC49C22128524D6C00BAA910 /* ThemeableCollectionViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC49C22028524D6C00BAA910 /* ThemeableCollectionViewCell.swift */; }; @@ -344,6 +359,9 @@ DC63208321FCAC1E007EC0A8 /* ClientActivityViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC63208221FCAC1E007EC0A8 /* ClientActivityViewController.swift */; }; DC63208521FCEBE9007EC0A8 /* ClientActivityCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC63208421FCEBE9007EC0A8 /* ClientActivityCell.swift */; }; DC6428D02081406800493A01 /* CollapsibleProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC6428CF2081406800493A01 /* CollapsibleProgressBar.swift */; }; + DC65590F28A2633C0003D130 /* UITextField+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC65590E28A2633C0003D130 /* UITextField+Extension.swift */; }; + DC65592E28A644E10003D130 /* SearchTokenizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC65592D28A644E10003D130 /* SearchTokenizer.swift */; }; + DC65593228A648680003D130 /* SearchElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC65593128A648680003D130 /* SearchElement.swift */; }; DC66A9F4279EEBF900792AC8 /* ThemeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 39B394482385334D00892E8D /* ThemeView.swift */; }; DC66A9F6279EEC3100792AC8 /* ResourceViewHost.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC66A9F5279EEC3100792AC8 /* ResourceViewHost.swift */; }; DC66A9F8279F467200792AC8 /* UIKeyCommand+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC66A9F7279F467200792AC8 /* UIKeyCommand+Extension.swift */; }; @@ -382,6 +400,10 @@ DC973BBE24A28ED0001DEEC4 /* CoreServices.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DCEC3DE3242F665D0076B43C /* CoreServices.framework */; }; DC98BBCB20FF815C00F4ED3E /* NSNumber+OCSyncAnchorData.m in Sources */ = {isa = PBXBuildFile; fileRef = DC98BBCA20FF815C00F4ED3E /* NSNumber+OCSyncAnchorData.m */; }; DC98BBD420FF824600F4ED3E /* FileProviderEnumeratorObserver.m in Sources */ = {isa = PBXBuildFile; fileRef = DC98BBD320FF824600F4ED3E /* FileProviderEnumeratorObserver.m */; }; + DC99154C28E636A500DA0AB8 /* SegmentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC99154B28E636A500DA0AB8 /* SegmentView.swift */; }; + DC99154E28E6371500DA0AB8 /* SegmentViewItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC99154D28E6371500DA0AB8 /* SegmentViewItem.swift */; }; + DC99155028E63D8300DA0AB8 /* SegmentViewItemView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC99154F28E63D8300DA0AB8 /* SegmentViewItemView.swift */; }; + DC99155228E63ECD00DA0AB8 /* UIView+EmbedAndLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC99155128E63ECD00DA0AB8 /* UIView+EmbedAndLayout.swift */; }; DC9A116B27D0338400D90BA4 /* ClientSpacesTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC9A116A27D0338400D90BA4 /* ClientSpacesTableViewController.swift */; }; DC9BFBB320A19AF4007064B5 /* doc in Resources */ = {isa = PBXBuildFile; fileRef = DC9BFBB220A19AF3007064B5 /* doc */; }; DC9BFBBD20A1C37B007064B5 /* PasswordManagerAccess.swift in Sources */ = {isa = PBXBuildFile; fileRef = DC9BFBBC20A1C37B007064B5 /* PasswordManagerAccess.swift */; }; @@ -432,6 +454,7 @@ DCC8536023CE1AF8007BA3EB /* PurchasesSettingsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCC8535F23CE1AF8007BA3EB /* PurchasesSettingsSection.swift */; }; DCCD77792604C91600098573 /* NSDate+ComputedTimes.h in Headers */ = {isa = PBXBuildFile; fileRef = DCCD776A2604C81B00098573 /* NSDate+ComputedTimes.h */; settings = {ATTRIBUTES = (Public, ); }; }; DCCD778C2604C91B00098573 /* NSDate+ComputedTimes.m in Sources */ = {isa = PBXBuildFile; fileRef = DCCD776B2604C81B00098573 /* NSDate+ComputedTimes.m */; }; + DCCE1BFA28CA45D90098E3FE /* ItemSearchSuggestionsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCCE1BF928CA45D90098E3FE /* ItemSearchSuggestionsViewController.swift */; }; DCD1300A23A191C000255779 /* LicenseOfferButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCD1300923A191C000255779 /* LicenseOfferButton.swift */; }; DCD1301123A23F4E00255779 /* OCLicenseManager+AppStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCD1301023A23F4E00255779 /* OCLicenseManager+AppStore.swift */; }; DCD2D40622F06ECA0071FB8F /* DataSettingsSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCD2D40522F06ECA0071FB8F /* DataSettingsSection.swift */; }; @@ -555,6 +578,7 @@ DCFC9ED128002335005D9144 /* CollectionViewCellProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCFC9ED028002335005D9144 /* CollectionViewCellProvider.swift */; }; DCFC9ED3280023BB005D9144 /* CollectionViewCellProvider+StandardImplementations.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCFC9ED2280023BB005D9144 /* CollectionViewCellProvider+StandardImplementations.swift */; }; DCFC9ED528002F33005D9144 /* CollectionViewCellConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCFC9ED428002F33005D9144 /* CollectionViewCellConfiguration.swift */; }; + DCFE682E28D9CEDD00091D2A /* ComposedMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCFE682D28D9CEDD00091D2A /* ComposedMessageView.swift */; }; DCFEF90926EFA45A001DC7A4 /* VendorServices+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = DCFEF90526EFA45A001DC7A4 /* VendorServices+App.swift */; }; DCFEFE2A236876BD009A142F /* OCLicenseManager.h in Headers */ = {isa = PBXBuildFile; fileRef = DCFEFE28236876BD009A142F /* OCLicenseManager.h */; settings = {ATTRIBUTES = (Public, ); }; }; DCFEFE2B236876BD009A142F /* OCLicenseManager.m in Sources */ = {isa = PBXBuildFile; fileRef = DCFEFE29236876BD009A142F /* OCLicenseManager.m */; }; @@ -1264,6 +1288,14 @@ DC24B27225B9DF31005783E2 /* Branding.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = Branding.m; sourceTree = ""; }; DC24B2AA25BA316D005783E2 /* Branding+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Branding+App.swift"; sourceTree = ""; }; DC24B31C25BB6FC4005783E2 /* IssuesCardViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IssuesCardViewController.swift; sourceTree = ""; }; + DC24E0E828B36A81002E4F5B /* OCSearchSegment.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OCSearchSegment.h; sourceTree = ""; }; + DC24E0E928B36A81002E4F5B /* OCSearchSegment.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OCSearchSegment.m; sourceTree = ""; }; + DC24E0F728B41693002E4F5B /* OCQueryCondition+SearchToken.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OCQueryCondition+SearchToken.swift"; sourceTree = ""; }; + DC24E10328B7BF4E002E4F5B /* CustomQuerySearchTokenizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomQuerySearchTokenizer.swift; sourceTree = ""; }; + DC24E10628B7BFD6002E4F5B /* ItemSearchScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSearchScope.swift; sourceTree = ""; }; + DC24E10A28B7C185002E4F5B /* SingleFolderSearchScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleFolderSearchScope.swift; sourceTree = ""; }; + DC24E10C28B7C19F002E4F5B /* AccountSearchScope.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountSearchScope.swift; sourceTree = ""; }; + DC24E10E28B7D2B9002E4F5B /* PopupButtonController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PopupButtonController.swift; sourceTree = ""; }; DC2565E8225F5A1900828AA5 /* UserNotifications.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UserNotifications.framework; path = System/Library/Frameworks/UserNotifications.framework; sourceTree = SDKROOT; }; DC26ADDD2550C0B20059680D /* MetadataDocumentationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetadataDocumentationTests.swift; sourceTree = ""; }; DC27A18C20CA9F66008ACB6C /* OCItem+FileProviderItem.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "OCItem+FileProviderItem.h"; sourceTree = ""; }; @@ -1281,6 +1313,10 @@ DC297968226E52E600E01BC7 /* PushTransition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushTransition.swift; sourceTree = ""; }; DC29F08F22974AEA00F77349 /* QueryFileListTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QueryFileListTableViewController.swift; sourceTree = ""; }; DC29F09222976B9200F77349 /* LibrarySharesTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = LibrarySharesTableViewController.swift; path = ownCloud/Client/Library/LibrarySharesTableViewController.swift; sourceTree = SOURCE_ROOT; }; + DC2A127C28D06F060088A2B7 /* OCSavedSearch.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OCSavedSearch.h; sourceTree = ""; }; + DC2A127D28D06F060088A2B7 /* OCSavedSearch.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = OCSavedSearch.m; sourceTree = ""; }; + DC2A128028D0718B0088A2B7 /* OCVault+SavedSearches.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "OCVault+SavedSearches.h"; sourceTree = ""; }; + DC2A128128D0718B0088A2B7 /* OCVault+SavedSearches.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "OCVault+SavedSearches.m"; sourceTree = ""; }; DC321260207EB01B00DB171D /* ThemeImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeImage.swift; sourceTree = ""; }; DC33939522E0747400DD3DA4 /* MakeAvailableOfflineAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MakeAvailableOfflineAction.swift; sourceTree = ""; }; DC33939C22E076E300DD3DA4 /* MakeUnavailableOfflineAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MakeUnavailableOfflineAction.swift; sourceTree = ""; }; @@ -1323,6 +1359,9 @@ DC46F3D22845583D00038880 /* OCDrive+Interactions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OCDrive+Interactions.swift"; sourceTree = ""; }; DC46F3D4284563BD00038880 /* OCAction+Interactions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OCAction+Interactions.swift"; sourceTree = ""; }; DC46F3D62845FCFA00038880 /* UIView+OCDataItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+OCDataItem.swift"; sourceTree = ""; }; + DC46F68D28DAFCDD008280CA /* RoundCornerBackgroundView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoundCornerBackgroundView.swift; sourceTree = ""; }; + DC46F68F28DCA1B8008280CA /* SavedSearchCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedSearchCell.swift; sourceTree = ""; }; + DC46F69128DCB9C6008280CA /* OCSavedSearch+Interactions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OCSavedSearch+Interactions.swift"; sourceTree = ""; }; DC49B55728365C5F00DAF13B /* OCVault+VFSManager.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "OCVault+VFSManager.h"; sourceTree = ""; }; DC49B55828365C5F00DAF13B /* OCVault+VFSManager.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "OCVault+VFSManager.m"; sourceTree = ""; }; DC49C22028524D6C00BAA910 /* ThemeableCollectionViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeableCollectionViewCell.swift; sourceTree = ""; }; @@ -1342,6 +1381,9 @@ DC63208421FCEBE9007EC0A8 /* ClientActivityCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientActivityCell.swift; sourceTree = ""; }; DC63208621FCEE5D007EC0A8 /* ProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressView.swift; sourceTree = ""; }; DC6428CF2081406800493A01 /* CollapsibleProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollapsibleProgressBar.swift; sourceTree = ""; }; + DC65590E28A2633C0003D130 /* UITextField+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UITextField+Extension.swift"; sourceTree = ""; }; + DC65592D28A644E10003D130 /* SearchTokenizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchTokenizer.swift; sourceTree = ""; }; + DC65593128A648680003D130 /* SearchElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchElement.swift; sourceTree = ""; }; DC66A9F5279EEC3100792AC8 /* ResourceViewHost.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResourceViewHost.swift; sourceTree = ""; }; DC66A9F7279F467200792AC8 /* UIKeyCommand+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIKeyCommand+Extension.swift"; sourceTree = ""; }; DC66F39A239659C000CF4812 /* OCASN1.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = OCASN1.h; sourceTree = ""; }; @@ -1387,6 +1429,10 @@ DC98BBCA20FF815C00F4ED3E /* NSNumber+OCSyncAnchorData.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSNumber+OCSyncAnchorData.m"; sourceTree = ""; }; DC98BBD220FF824600F4ED3E /* FileProviderEnumeratorObserver.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FileProviderEnumeratorObserver.h; sourceTree = ""; }; DC98BBD320FF824600F4ED3E /* FileProviderEnumeratorObserver.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FileProviderEnumeratorObserver.m; sourceTree = ""; }; + DC99154B28E636A500DA0AB8 /* SegmentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentView.swift; sourceTree = ""; }; + DC99154D28E6371500DA0AB8 /* SegmentViewItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentViewItem.swift; sourceTree = ""; }; + DC99154F28E63D8300DA0AB8 /* SegmentViewItemView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentViewItemView.swift; sourceTree = ""; }; + DC99155128E63ECD00DA0AB8 /* UIView+EmbedAndLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIView+EmbedAndLayout.swift"; sourceTree = ""; }; DC9A116A27D0338400D90BA4 /* ClientSpacesTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientSpacesTableViewController.swift; sourceTree = ""; }; DC9BFBB220A19AF3007064B5 /* doc */ = {isa = PBXFileReference; lastKnownFileType = folder; path = doc; sourceTree = ""; }; DC9BFBB820A1AF2B007064B5 /* icon-locked.tvg */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; name = "icon-locked.tvg"; path = "img/filetypes-tvg/icon-locked.tvg"; sourceTree = SOURCE_ROOT; }; @@ -1452,6 +1498,7 @@ DCC8535F23CE1AF8007BA3EB /* PurchasesSettingsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchasesSettingsSection.swift; sourceTree = ""; }; DCCD776A2604C81B00098573 /* NSDate+ComputedTimes.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "NSDate+ComputedTimes.h"; sourceTree = ""; }; DCCD776B2604C81B00098573 /* NSDate+ComputedTimes.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "NSDate+ComputedTimes.m"; sourceTree = ""; }; + DCCE1BF928CA45D90098E3FE /* ItemSearchSuggestionsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ItemSearchSuggestionsViewController.swift; sourceTree = ""; }; DCD1300923A191C000255779 /* LicenseOfferButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LicenseOfferButton.swift; sourceTree = ""; }; DCD1301023A23F4E00255779 /* OCLicenseManager+AppStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "OCLicenseManager+AppStore.swift"; sourceTree = ""; }; DCD2D40522F06ECA0071FB8F /* DataSettingsSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataSettingsSection.swift; sourceTree = ""; }; @@ -1550,6 +1597,7 @@ DCFC9ED028002335005D9144 /* CollectionViewCellProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewCellProvider.swift; sourceTree = ""; }; DCFC9ED2280023BB005D9144 /* CollectionViewCellProvider+StandardImplementations.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CollectionViewCellProvider+StandardImplementations.swift"; sourceTree = ""; }; DCFC9ED428002F33005D9144 /* CollectionViewCellConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewCellConfiguration.swift; sourceTree = ""; }; + DCFE682D28D9CEDD00091D2A /* ComposedMessageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ComposedMessageView.swift; sourceTree = ""; }; DCFED971208095E200A2D984 /* ClientItemCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientItemCell.swift; sourceTree = ""; }; DCFED9B920809B8900A2D984 /* ThemeTVGResource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThemeTVGResource.swift; sourceTree = ""; }; DCFEF90526EFA45A001DC7A4 /* VendorServices+App.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "VendorServices+App.swift"; sourceTree = ""; }; @@ -1985,6 +2033,7 @@ DC3AB24328104AA500789435 /* UIFont+Weight.swift */, DC3AB2452810602500789435 /* UILabel+Extension.swift */, DC46F3D62845FCFA00038880 /* UIView+OCDataItem.swift */, + DC65590E28A2633C0003D130 /* UITextField+Extension.swift */, ); path = "UIKit Extension"; sourceTree = ""; @@ -2277,6 +2326,7 @@ DC0A354D24C0E15100FB58FC /* Progress */, DC297959226E4C9200E01BC7 /* Push Presentation Controller */, DC0A354E24C0E15900FB58FC /* StaticTableView */, + DC99154A28E6364700DA0AB8 /* SegmentView */, DCE4E43224C197480051722F /* User Activities */, ); path = "User Interface"; @@ -2367,6 +2417,35 @@ path = Branding; sourceTree = ""; }; + DC24E10228B7BF13002E4F5B /* Tokenizer */ = { + isa = PBXGroup; + children = ( + DC24E10328B7BF4E002E4F5B /* CustomQuerySearchTokenizer.swift */, + DC24E0F728B41693002E4F5B /* OCQueryCondition+SearchToken.swift */, + ); + path = Tokenizer; + sourceTree = ""; + }; + DC24E10828B7C04B002E4F5B /* Item Search */ = { + isa = PBXGroup; + children = ( + DC24E10228B7BF13002E4F5B /* Tokenizer */, + DCCE1BF928CA45D90098E3FE /* ItemSearchSuggestionsViewController.swift */, + DC24E10928B7C05E002E4F5B /* Scopes */, + ); + path = "Item Search"; + sourceTree = ""; + }; + DC24E10928B7C05E002E4F5B /* Scopes */ = { + isa = PBXGroup; + children = ( + DC24E10628B7BFD6002E4F5B /* ItemSearchScope.swift */, + DC24E10A28B7C185002E4F5B /* SingleFolderSearchScope.swift */, + DC24E10C28B7C19F002E4F5B /* AccountSearchScope.swift */, + ); + path = Scopes; + sourceTree = ""; + }; DC255E432319AD13007279B1 /* Scanner */ = { isa = PBXGroup; children = ( @@ -2414,6 +2493,29 @@ path = "FileList Extensions"; sourceTree = ""; }; + DC2A127B28D06EED0088A2B7 /* Saved Searches */ = { + isa = PBXGroup; + children = ( + DC2A127D28D06F060088A2B7 /* OCSavedSearch.m */, + DC2A127C28D06F060088A2B7 /* OCSavedSearch.h */, + DC2A128128D0718B0088A2B7 /* OCVault+SavedSearches.m */, + DC2A128028D0718B0088A2B7 /* OCVault+SavedSearches.h */, + ); + path = "Saved Searches"; + sourceTree = ""; + }; + DC2A128828D076750088A2B7 /* Search */ = { + isa = PBXGroup; + children = ( + DC24E0E928B36A81002E4F5B /* OCSearchSegment.m */, + DC24E0E828B36A81002E4F5B /* OCSearchSegment.h */, + DCB458EC2604A7D4006A02AB /* OCQueryCondition+SearchSegmenter.m */, + DCB458EB2604A7D4006A02AB /* OCQueryCondition+SearchSegmenter.h */, + DC2A127B28D06EED0088A2B7 /* Saved Searches */, + ); + path = Search; + sourceTree = ""; + }; DC33939E22E0A1A300DD3DA4 /* Item Policies */ = { isa = PBXGroup; children = ( @@ -2507,13 +2609,23 @@ isa = PBXGroup; children = ( DC46F3C62844A75200038880 /* OCDataItem+InteractionProtocols.swift */, - DC46F3D0284546F000038880 /* OCItem+Interactions.swift */, - DC46F3D22845583D00038880 /* OCDrive+Interactions.swift */, DC46F3D4284563BD00038880 /* OCAction+Interactions.swift */, + DC46F3D22845583D00038880 /* OCDrive+Interactions.swift */, + DC46F3D0284546F000038880 /* OCItem+Interactions.swift */, + DC46F69128DCB9C6008280CA /* OCSavedSearch+Interactions.swift */, ); path = "Data Item Interactions"; sourceTree = ""; }; + DC65592C28A644B60003D130 /* Tokenizer */ = { + isa = PBXGroup; + children = ( + DC65592D28A644E10003D130 /* SearchTokenizer.swift */, + DC65593128A648680003D130 /* SearchElement.swift */, + ); + path = Tokenizer; + sourceTree = ""; + }; DC66F3A723965BE300CF4812 /* Parser Support */ = { isa = PBXGroup; children = ( @@ -2550,8 +2662,6 @@ DC774E6122F44E6D000B11A1 /* OCCore+BundleImport.h */, DC7C100F24B5F81E00227085 /* OCBookmark+AppExtensions.m */, DC7C100E24B5F81E00227085 /* OCBookmark+AppExtensions.h */, - DCB458EC2604A7D4006A02AB /* OCQueryCondition+SearchSegmenter.m */, - DCB458EB2604A7D4006A02AB /* OCQueryCondition+SearchSegmenter.h */, ); path = "SDK Extensions"; sourceTree = ""; @@ -2706,6 +2816,17 @@ path = Licensing; sourceTree = ""; }; + DC99154A28E6364700DA0AB8 /* SegmentView */ = { + isa = PBXGroup; + children = ( + DC99154B28E636A500DA0AB8 /* SegmentView.swift */, + DC99154D28E6371500DA0AB8 /* SegmentViewItem.swift */, + DC99154F28E63D8300DA0AB8 /* SegmentViewItemView.swift */, + DC99155128E63ECD00DA0AB8 /* UIView+EmbedAndLayout.swift */, + ); + path = SegmentView; + sourceTree = ""; + }; DCA2EDDB279B0E5D001F04E6 /* Resource Sources */ = { isa = PBXGroup; children = ( @@ -2771,6 +2892,8 @@ children = ( DCB5D56A2861BEBE004AF425 /* SearchViewController.swift */, DCB5D5A828632C1B004AF425 /* Scopes */, + DC65592C28A644B60003D130 /* Tokenizer */, + DC24E10828B7C04B002E4F5B /* Item Search */, ); path = Search; sourceTree = ""; @@ -2831,6 +2954,7 @@ DCF072D02798504E00E0B01D /* Views */, DCF575E52796CBB3003BEBBA /* View Providers */, DC774E5422F44DF6000B11A1 /* SDK Extensions */, + DC2A128828D076750088A2B7 /* Search */, DC0030BE2350B1CE00BB8570 /* Tools */, DCEA7F38282D3ACA0050A3C0 /* VFS */, DC774E5B22F44E4A000B11A1 /* ZIP Archive */, @@ -3042,13 +3166,16 @@ DCE4E42F24C1963F0051722F /* User Interface */ = { isa = PBXGroup; children = ( - 23D77FC6212BFBD100DE76F1 /* NamingViewController.swift */, 394804D9225CBDBA00AA8183 /* BreadCrumbTableViewController.swift */, DCFED971208095E200A2D984 /* ClientItemCell.swift */, + DCFE682D28D9CEDD00091D2A /* ComposedMessageView.swift */, + DCD864112811FC5700CA6631 /* GradientView.swift */, 39B289A7226F1EE000BE0E11 /* MessageView.swift */, - 3912208123436EB80026C290 /* SortMethod.swift */, + 23D77FC6212BFBD100DE76F1 /* NamingViewController.swift */, + DC24E10E28B7D2B9002E4F5B /* PopupButtonController.swift */, + DC46F68D28DAFCDD008280CA /* RoundCornerBackgroundView.swift */, 23FA23E520BFD3D8009A6D73 /* SortBar.swift */, - DCD864112811FC5700CA6631 /* GradientView.swift */, + 3912208123436EB80026C290 /* SortMethod.swift */, ); path = "User Interface"; sourceTree = ""; @@ -3161,6 +3288,7 @@ DCEAF0892808254800980B6D /* DriveListCell.swift */, DC3AB2472810A10300789435 /* ExpandableResourceCell.swift */, DC3AB23D280FFE3400789435 /* ItemListCell.swift */, + DC46F68F28DCA1B8008280CA /* SavedSearchCell.swift */, DC49C22028524D6C00BAA910 /* ThemeableCollectionViewCell.swift */, DC01AF2028411D8400903101 /* ThemeableCollectionViewListCell.swift */, DC46F3CB2844A8EA00038880 /* ViewCell.swift */, @@ -3412,8 +3540,10 @@ DCFEFE4923687C83009A142F /* OCLicenseEntitlement.h in Headers */, DC4332002472E1B4002DC0E5 /* OCLicenseEMMProvider.h in Headers */, DCF072DC279857A300E0B01D /* OCCircularContentView.h in Headers */, + DC2A128428D0722F0088A2B7 /* OCSavedSearch.h in Headers */, DCFEFE39236877A7009A142F /* OCLicenseFeature.h in Headers */, DC23D1DA238F391200423F62 /* OCLicenseAppStoreReceipt.h in Headers */, + DC24E0EA28B36A81002E4F5B /* OCSearchSegment.h in Headers */, DC70398526128B89009F2DC1 /* NSString+ByteCountParser.h in Headers */, DCF072EC27986CCA00E0B01D /* OCResourceTextPlaceholder+ViewProvider.h in Headers */, DCF2DA8324C83BFB0026D790 /* OCFileProviderService.h in Headers */, @@ -3453,6 +3583,7 @@ DC049156258C00C400DEDC27 /* OCFileProviderServiceStandby.h in Headers */, DCFEFE972368D099009A142F /* OCLicenseEnvironment.h in Headers */, DC6A0E5426EA9E740076B533 /* AppLockSettings.h in Headers */, + DC2A128528D072350088A2B7 /* OCVault+SavedSearches.h in Headers */, DC49B55928365C5F00DAF13B /* OCVault+VFSManager.h in Headers */, DC36885824DC98BF00333600 /* OCFileProviderServiceSession.h in Headers */, ); @@ -3747,7 +3878,7 @@ }; 394A0AF822EEFC2C00603813 = { CreatedOnToolsVersion = 11.0; - LastSwiftMigration = 1330; + LastSwiftMigration = 1340; ProvisioningStyle = Automatic; }; 39A7137F22E79C6700089423 = { @@ -4390,11 +4521,14 @@ DC04FFC827F5B79000F22569 /* CollectionViewController.swift in Sources */, DC3AB2422810404000789435 /* DriveHeaderCell.swift in Sources */, DCE4E44524C1A4260051722F /* FileListTableViewController.swift in Sources */, + DC65590F28A2633C0003D130 /* UITextField+Extension.swift in Sources */, + DC24E10728B7BFD6002E4F5B /* ItemSearchScope.swift in Sources */, DC46F3D72845FCFA00038880 /* UIView+OCDataItem.swift in Sources */, 399EA75A25E66DB000B6FF11 /* ClientItemResolvingCell.swift in Sources */, DCE4E43524C1999A0051722F /* Action.swift in Sources */, DC46F3D5284563BD00038880 /* OCAction+Interactions.swift in Sources */, DCFC9ECC28002303005D9144 /* CollectionViewSection.swift in Sources */, + DC99155028E63D8300DA0AB8 /* SegmentViewItemView.swift in Sources */, DC3AB2462810602500789435 /* UILabel+Extension.swift in Sources */, DC0A356C24C0E42200FB58FC /* AppLockWindow.swift in Sources */, DCFC9ED128002335005D9144 /* CollectionViewCellProvider.swift in Sources */, @@ -4408,9 +4542,11 @@ DC0A357024C0E42700FB58FC /* StaticTableViewSection.swift in Sources */, DC0A357624C0E43200FB58FC /* ProgressHUDViewController.swift in Sources */, 399EA6F925E6544100B6FF11 /* SharingTableViewController.swift in Sources */, + DC46F69228DCB9C6008280CA /* OCSavedSearch+Interactions.swift in Sources */, DCE4E45824C1F0F40051722F /* ClientDirectoryPickerViewController.swift in Sources */, DCDC0AD123CD18D200DFE36D /* OCLicenseManager+Setup.swift in Sources */, DCE4E44724C1AC4F0051722F /* MessageView.swift in Sources */, + DCFE682E28D9CEDD00091D2A /* ComposedMessageView.swift in Sources */, DCE4E48824C1FA430051722F /* NamingViewController.swift in Sources */, DC01AF2128411D8400903101 /* ThemeableCollectionViewListCell.swift in Sources */, 399725E1233DF39300FC3B94 /* Calendar+Extension.swift in Sources */, @@ -4431,6 +4567,7 @@ DCE4E44F24C1DF130051722F /* UIViewController+Extension.swift in Sources */, DC0A357F24C0E43C00FB58FC /* ThemeStyle+DefaultStyles.swift in Sources */, DCB5D60B25FC14B6004C52D9 /* OCIssue+Extension.swift in Sources */, + DC46F69028DCA1B8008280CA /* SavedSearchCell.swift in Sources */, DC66A9F4279EEBF900792AC8 /* ThemeView.swift in Sources */, DC0A356F24C0E42700FB58FC /* StaticTableViewController.swift in Sources */, DC0A357224C0E42D00FB58FC /* PushPresentationController.swift in Sources */, @@ -4451,9 +4588,13 @@ DCE4E48724C1F9F50051722F /* CreateFolderAction.swift in Sources */, 39E6DE86233CDF1E008DAE04 /* OCItemTracker.swift in Sources */, DC0A358624C0E44600FB58FC /* ThemeTVGResource.swift in Sources */, + DC99155228E63ECD00DA0AB8 /* UIView+EmbedAndLayout.swift in Sources */, + DC46F68E28DAFCDD008280CA /* RoundCornerBackgroundView.swift in Sources */, DC3AB24428104AA500789435 /* UIFont+Weight.swift in Sources */, DC0A358524C0E44600FB58FC /* ThemeResource.swift in Sources */, + DC99154C28E636A500DA0AB8 /* SegmentView.swift in Sources */, DCD864122811FC5700CA6631 /* GradientView.swift in Sources */, + DC99154E28E6371500DA0AB8 /* SegmentViewItem.swift in Sources */, DCFC9ED528002F33005D9144 /* CollectionViewCellConfiguration.swift in Sources */, DC0A355624C0E33A00FB58FC /* Log.swift in Sources */, DC0A359624C0E61500FB58FC /* UIView+Extension.swift in Sources */, @@ -4465,18 +4606,23 @@ DCE4E43124C197450051722F /* OpenItemUserActivity.swift in Sources */, DCFC9ED3280023BB005D9144 /* CollectionViewCellProvider+StandardImplementations.swift in Sources */, DC0A358D24C0E44B00FB58FC /* ThemedAlertController.swift in Sources */, + DC24E10F28B7D2B9002E4F5B /* PopupButtonController.swift in Sources */, DCE4E43C24C19B660051722F /* FrameViewController.swift in Sources */, DC0A35A124C1091400FB58FC /* UserInterfaceContext.swift in Sources */, DC0A357424C0E42D00FB58FC /* PushTransition.swift in Sources */, DC0A357824C0E43700FB58FC /* CardPresentationController.swift in Sources */, 393D2B3F23FEB6DC00ED4F8C /* DispatchQueueTools.swift in Sources */, DC66A9F6279EEC3100792AC8 /* ResourceViewHost.swift in Sources */, + DC65592E28A644E10003D130 /* SearchTokenizer.swift in Sources */, + DC24E0F828B41694002E4F5B /* OCQueryCondition+SearchToken.swift in Sources */, DC0A357324C0E42D00FB58FC /* PushTransitionDelegate.swift in Sources */, DCE4E45424C1EC040051722F /* BreadCrumbTableViewController.swift in Sources */, 399EA73A25E656A900B6FF11 /* UITableView+Extension.swift in Sources */, 399EA6F725E6544100B6FF11 /* PublicLinkEditTableViewController.swift in Sources */, DC46F3C72844A75200038880 /* OCDataItem+InteractionProtocols.swift in Sources */, + DCCE1BFA28CA45D90098E3FE /* ItemSearchSuggestionsViewController.swift in Sources */, DC0A358824C0E44B00FB58FC /* ThemeTableViewCell.swift in Sources */, + DC24E10428B7BF4E002E4F5B /* CustomQuerySearchTokenizer.swift in Sources */, DC36886224DDA9AB00333600 /* ProgressIndicatorViewController.swift in Sources */, 399EA72625E6565900B6FF11 /* OCCore+Extension.swift in Sources */, DC0A359424C0E5C800FB58FC /* GitCommit.swift in Sources */, @@ -4497,10 +4643,13 @@ DC0A358324C0E44200FB58FC /* VectorImage.swift in Sources */, DC0A359824C0E68700FB58FC /* OCItem+AppExtension.swift in Sources */, DC0A358424C0E44200FB58FC /* VectorImageView.swift in Sources */, + DC24E10D28B7C19F002E4F5B /* AccountSearchScope.swift in Sources */, + DC65593228A648680003D130 /* SearchElement.swift in Sources */, DC0A355324C0E2C200FB58FC /* ClientItemCell.swift in Sources */, DC0A357D24C0E43C00FB58FC /* ThemeStyle.swift in Sources */, DC46F3CC2844A8EA00038880 /* ViewCell.swift in Sources */, DC0A357524C0E43200FB58FC /* ProgressView.swift in Sources */, + DC24E10B28B7C185002E4F5B /* SingleFolderSearchScope.swift in Sources */, 3912208223436EB80026C290 /* SortMethod.swift in Sources */, DCE4E43F24C19D370051722F /* UIAlertController+OCIssue.swift in Sources */, DC0A359524C0E5F900FB58FC /* UIImage+Extension.swift in Sources */, @@ -4576,6 +4725,7 @@ DCCD778C2604C91B00098573 /* NSDate+ComputedTimes.m in Sources */, DC66F3A623965A1400CF4812 /* NSDate+RFC3339.m in Sources */, DCF575EC2796CBDF003BEBBA /* OCImage+ViewProvider.m in Sources */, + DC24E0EB28B36A81002E4F5B /* OCSearchSegment.m in Sources */, DCF2DA8724C87A330026D790 /* OCCore+FPServices.m in Sources */, DC7C101224B5FD6500227085 /* OCBookmark+AppExtensions.m in Sources */, DC4332012472E1B4002DC0E5 /* OCLicenseEMMProvider.m in Sources */, @@ -4583,6 +4733,7 @@ DCFEFE3E236877B7009A142F /* OCLicenseProduct.m in Sources */, DC774E6422F44E6D000B11A1 /* OCCore+BundleImport.m in Sources */, DCF2DA7E24C835BF0026D790 /* OCVault+FPServices.m in Sources */, + DC2A128728D0725D0088A2B7 /* OCSavedSearch.m in Sources */, DCDC208D239912DC003CFF5B /* OCLicenseTransaction.m in Sources */, DC23D1D9238F390A00423F62 /* OCLicenseAppStoreReceipt.m in Sources */, DCC832F2242CC28400153F8C /* NotificationMessagePresenter.m in Sources */, @@ -4603,6 +4754,7 @@ DC080CE5238AE3F40044C5D2 /* OCLicenseAppStoreProvider.m in Sources */, DC6A0E5526EA9E740076B533 /* AppLockSettings.m in Sources */, DCC0857F2293F48D008CC05C /* DisplaySettings.m in Sources */, + DC2A128628D0725D0088A2B7 /* OCVault+SavedSearches.m in Sources */, DCB2C061250C253C001083CA /* BrandingClassSettingsSource.m in Sources */, DCFEFE2F236876D4009A142F /* OCLicenseProvider.m in Sources */, DCC832F4242CC28F00153F8C /* NotificationManager.m in Sources */, @@ -4984,7 +5136,7 @@ APP_BUILD_FLAGS_SWIFT = "$(APP_BUILD_FLAGS)"; APP_PRODUCT_NAME = ownCloud; APP_SHORT_VERSION = 12.0; - APP_VERSION = 224; + APP_VERSION = 228; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -5054,7 +5206,7 @@ APP_BUILD_FLAGS_SWIFT = "$(APP_BUILD_FLAGS)"; APP_PRODUCT_NAME = ownCloud; APP_SHORT_VERSION = 12.0; - APP_VERSION = 224; + APP_VERSION = 228; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; @@ -5249,6 +5401,7 @@ SKIP_INSTALL = YES; STRINGS_FILE_OUTPUT_ENCODING = "UTF-8"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; @@ -5282,6 +5435,7 @@ PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)"; SKIP_INSTALL = YES; STRINGS_FILE_OUTPUT_ENCODING = "UTF-8"; + SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; VERSIONING_SYSTEM = "apple-generic"; VERSION_INFO_PREFIX = ""; diff --git a/ownCloud.xcworkspace/xcshareddata/swiftpm/Package.resolved b/ownCloud.xcworkspace/xcshareddata/swiftpm/Package.resolved index b14d7da78..e2b9b6417 100644 --- a/ownCloud.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/ownCloud.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,5 +1,14 @@ { "pins" : [ + { + "identity" : "down", + "kind" : "remoteSourceControl", + "location" : "https://github.com/johnxnguyen/Down", + "state" : { + "branch" : "master", + "revision" : "e754ab1c80920dd51a8e08290c912ac1c2ac8b58" + } + }, { "identity" : "openssl", "kind" : "remoteSourceControl", @@ -14,8 +23,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/microsoft/plcrashreporter.git", "state" : { - "revision" : "4637a7854de2cc5c354d46fb931d74bdbc2c043e", - "version" : "1.7.0" + "revision" : "81cdec2b3827feb03286cb297f4c501a8eb98df1", + "version" : "1.10.2" } }, { diff --git a/ownCloud/Resources/en.lproj/Localizable.strings b/ownCloud/Resources/en.lproj/Localizable.strings index 4e8ef97ca..79963696d 100644 --- a/ownCloud/Resources/en.lproj/Localizable.strings +++ b/ownCloud/Resources/en.lproj/Localizable.strings @@ -105,7 +105,13 @@ "size" = "size"; "date" = "date"; "Search folder" = "Search folder"; +"Search tree" = "Search tree"; +"Search from {{folder.name}}" = "Search from {{folder.name}}"; +"Search space" = "Search space"; +"Search {{space.name}}" = "Search {{space.name}}"; "Search account" = "Search account"; +"Save search" = "Save search"; + "Pending" = "Pending"; "Show parent paths" = "Show parent paths"; "Reveal in folder" = "Reveal in folder"; @@ -117,6 +123,17 @@ "Show more results" = "Show more results"; +/* Saved searches */ +"Saved search views" = "Saved search views"; +"Saved search templates" = "Saved search templates"; +"Save as search view" = "Save as search view"; +"Save as search template" = "Save as search template"; + +"Name of view" = "Name of view"; +"Search view" = "Search view"; +"Name of template" = "Name of template"; +"Search template" = "Search template"; + /* Search scope */ "Search scope" = "Search scope"; "Toggle Search Scope" = "Toggle Search Scope"; @@ -142,6 +159,7 @@ /* Client Messages */ "Empty folder" = "Empty folder"; +"No contents" = "No contents"; "This folder contains no files or folders." = "This folder contains no files or folders."; "This folder is empty. Fill it with content:" = "This folder is empty. Fill it with content:"; @@ -160,6 +178,7 @@ "Upload files" = "Upload files"; "No matches" = "No matches"; +"The search term you entered did not match any item in the selected scope." = "The search term you entered did not match any item in the selected scope."; "There are no results for this search term" = "There are no results for this search term"; "Status" = "Status"; diff --git a/ownCloudAppFramework/Resources/en.lproj/Localizable.strings b/ownCloudAppFramework/Resources/en.lproj/Localizable.strings index df22ef621..e15b126b3 100644 --- a/ownCloudAppFramework/Resources/en.lproj/Localizable.strings +++ b/ownCloudAppFramework/Resources/en.lproj/Localizable.strings @@ -56,6 +56,11 @@ "keyword_folder" = "folder"; "keyword_image" = "image"; "keyword_video" = "video"; +"keyword_audio" = "audio"; +"keyword_document" = "document"; +"keyword_spreadsheet" = "spreadsheet"; +"keyword_presentation" = "presentation"; +"keyword_pdf" = "pdf"; "keyword_today" = "today"; "keyword_week" = "week"; "keyword_month" = "month"; @@ -64,3 +69,66 @@ "keyword_w" = "w"; /* short-form for "week" */ "keyword_m" = "m"; /* short-form for "month" */ "keyword_y" = "y"; /* short-form for "year" */ + +/* Search token labels */ +"No folder" = "No folder"; +"Folder" = "Folder"; + +"No file" = "No file"; +"File" = "File"; + +"No image" = "No image"; +"Image" = "Image"; + +"No video" = "No video"; +"Video" = "Video"; + +"No audio" = "No audio"; +"Audio" = "Audio"; + +"Document" = "Document"; +"No Document" = "No Document"; + +"Spreadsheet" = "Spreadsheet"; +"No spreadsheet" = "No spreadsheet"; + +"Presentation" = "Presentation"; +"No presentation" = "No presentation"; + +"PDF" = "PDF"; +"No PDF" = "No PDF"; + +"Before" = "Before"; +"After" = "After"; + +"Not on" = "Not on"; +"On" = "On"; +"Not" = "Not"; + +"Before today" = "Before today"; +"Before yesterday" = "Before yesterday"; +">%d days ago" = ">%d days ago"; +"Today" = "Today"; +"Since yesterday" = "Since yesterday"; +"Last %d days" = "Last %d days"; + +"Before this week" = "Before this week"; +"Before last week" = "Before last week"; +">%d weeks ago" = ">%d weeks ago"; +"This week" = "This week"; +"Since last week" = "Since last week"; +"Last %d weeks" = "Last %d weeks"; + +"Before this month" = "Before this month"; +"Before last month" = "Before last month"; +"> %d months ago" = "> %d months ago"; +"This month" = "This month"; +"Since last month" = "Since last month"; +"Last %d months" = "Last %d months"; + +"Before this year" = "Before this year"; +"Before last year" = "Before last year"; +"> %d years ago" = "> %d years ago"; +"This year" = "This year"; +"Since last year" = "Since last year"; +"Last %d years" = "Last %d years"; diff --git a/ownCloudAppFramework/SDK Extensions/OCQueryCondition+SearchSegmenter.h b/ownCloudAppFramework/SDK Extensions/OCQueryCondition+SearchSegmenter.h deleted file mode 100644 index 03a5d35c0..000000000 --- a/ownCloudAppFramework/SDK Extensions/OCQueryCondition+SearchSegmenter.h +++ /dev/null @@ -1,36 +0,0 @@ -// -// OCQueryCondition+SearchSegmenter.h -// ownCloudApp -// -// Created by Felix Schwarz on 19.03.21. -// Copyright © 2021 ownCloud GmbH. All rights reserved. -// - -/* - * Copyright (C) 2021, ownCloud GmbH. - * - * This code is covered by the GNU Public License Version 3. - * - * For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ - * You should have received a copy of this license along with this program. If not, see . - * - */ - -#import - -NS_ASSUME_NONNULL_BEGIN - -@interface NSString (SearchSegmenter) - -- (NSArray *)segmentedForSearchWithQuotationMarks:(BOOL)withQuotationMarks; - -@end - -@interface OCQueryCondition (SearchSegmenter) - -+ (nullable instancetype)forSearchSegment:(NSString *)segmentString; -+ (nullable instancetype)fromSearchTerm:(NSString *)searchTerm; - -@end - -NS_ASSUME_NONNULL_END diff --git a/ownCloudAppFramework/SDK Extensions/OCQueryCondition+SearchSegmenter.m b/ownCloudAppFramework/SDK Extensions/OCQueryCondition+SearchSegmenter.m deleted file mode 100644 index dd40d0aa0..000000000 --- a/ownCloudAppFramework/SDK Extensions/OCQueryCondition+SearchSegmenter.m +++ /dev/null @@ -1,456 +0,0 @@ -// -// OCQueryCondition+SearchSegmenter.m -// ownCloudApp -// -// Created by Felix Schwarz on 19.03.21. -// Copyright © 2021 ownCloud GmbH. All rights reserved. -// - -/* - * Copyright (C) 2021, ownCloud GmbH. - * - * This code is covered by the GNU Public License Version 3. - * - * For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ - * You should have received a copy of this license along with this program. If not, see . - * - */ - -#import "OCQueryCondition+SearchSegmenter.h" -#import "NSDate+ComputedTimes.h" -#import "NSString+ByteCountParser.h" -#import "OCLicenseManager.h" // needed as localization "anchor" - -@implementation NSString (SearchSegmenter) - -- (BOOL)isQuotationMark -{ - return ([@"“”‘‛‟„‚'\"′″´˝❛❜❝❞" containsString:self]); -} - -- (BOOL)hasQuotationMarkSuffix -{ - if (self.length > 0) - { - return ([[self substringWithRange:NSMakeRange(self.length-1, 1)] isQuotationMark]); - } - - return (NO); -} - -- (BOOL)hasQuotationMarkPrefix -{ - if (self.length > 0) - { - return ([[self substringWithRange:NSMakeRange(0, 1)] isQuotationMark]); - } - - return (NO); -} - -- (NSArray *)segmentedForSearchWithQuotationMarks:(BOOL)withQuotationMarks -{ - NSMutableArray *segments = [NSMutableArray new]; - NSArray *terms; - - if ((terms = [self componentsSeparatedByString:@" "]) != nil) - { - __block NSString *segmentString = nil; - __block BOOL segmentOpen = NO; - __block BOOL isNegated = NO; - - void (^SubmitSegment)(void) = ^{ - if (segmentString.length > 0) - { - if (segmentOpen && withQuotationMarks) - { - [segments addObject:[NSString stringWithFormat:@"%@\"%@\"", (isNegated ? @"-" : @""), segmentString]]; - } - else - { - [segments addObject:(isNegated ? [@"-" stringByAppendingString:segmentString] : segmentString)]; - } - } - - segmentString = nil; - }; - - for (NSString *inTerm in terms) - { - NSString *term = inTerm; - BOOL closingSegment = NO; - - if (!segmentOpen) - { - isNegated = NO; - } - - if ([term hasPrefix:@"-"]) - { - // Negate segment - isNegated = YES; - term = [term substringFromIndex:1]; - } - - if ([term hasQuotationMarkPrefix]) - { - // Submit any open segment - SubmitSegment(); - - // Start new segment - term = [term substringFromIndex:1]; - segmentOpen = YES; - } - - if ([term hasQuotationMarkSuffix]) - { - // End segment - term = [term substringToIndex:term.length-1]; - closingSegment = YES; - } - - // Append term to current segment - if (segmentString.length == 0) - { - segmentString = term; - - if (!segmentOpen) - { - // Submit standalone segment - SubmitSegment(); - } - } - else - { - // Append to segment string - segmentString = [segmentString stringByAppendingFormat:@" %@", term]; - } - - // Submit closed segments - if (closingSegment) - { - SubmitSegment(); - segmentOpen = NO; - } - } - - SubmitSegment(); - } - - return (segments); -} - -@end - -@implementation OCQueryCondition (SearchSegmenter) - -+ (nullable NSString *)normalizeKeyword:(NSString *)keyword -{ - static dispatch_once_t onceToken; - static NSArray *keywords; - static NSDictionary *keywordByLocalizedKeyword; - - dispatch_once(&onceToken, ^{ - NSBundle *localizationBundle = [NSBundle bundleForClass:OCLicenseManager.class]; - - #define TranslateKeyword(keyword) [[localizationBundle localizedStringForKey:@"keyword_" keyword value:keyword table:@"Localizable"] lowercaseString] : keyword - - keywordByLocalizedKeyword = @{ - // Standalone keywords - TranslateKeyword(@"file"), - TranslateKeyword(@"folder"), - TranslateKeyword(@"image"), - TranslateKeyword(@"video"), - TranslateKeyword(@"today"), - TranslateKeyword(@"week"), - TranslateKeyword(@"month"), - TranslateKeyword(@"year"), - - // Modifier keywords - TranslateKeyword(@"type"), - TranslateKeyword(@"after"), - TranslateKeyword(@"before"), - TranslateKeyword(@"on"), - TranslateKeyword(@"smaller"), - TranslateKeyword(@"greater"), - TranslateKeyword(@"owner"), - - // Suffix keywords - TranslateKeyword(@"d"), - TranslateKeyword(@"w"), - TranslateKeyword(@"m"), - TranslateKeyword(@"y") - }; - - keywords = [keywordByLocalizedKeyword allValues]; - }); - - NSString *normalizedKeyword = nil; - - if (keyword != nil) - { - keyword = [keyword lowercaseString]; - - if ((normalizedKeyword = keywordByLocalizedKeyword[keyword]) == nil) - { - if ([keywords containsObject:keyword]) - { - normalizedKeyword = keyword; - } - } - } - - if ((normalizedKeyword == nil) && (keyword.length == 0)) - { - normalizedKeyword = keyword; - } - - return (normalizedKeyword); -} - -+ (instancetype)forSearchSegment:(NSString *)segmentString -{ - NSString *segmentStringLowercase = nil; - BOOL negateCondition = NO; - BOOL literalSearch = NO; - - if ([segmentString hasPrefix:@"-"]) - { - negateCondition = YES; - segmentString = [segmentString substringFromIndex:1]; - } - - if ([segmentString hasPrefix:@"\""] && [segmentString hasSuffix:@"\""] && (segmentString.length >= 2)) - { - literalSearch = YES; - segmentString = [segmentString substringWithRange:NSMakeRange(1, segmentString.length-2)]; - } - - if (segmentString.length == 0) - { - return (nil); - } - - segmentStringLowercase = segmentString.lowercaseString; - - if ([segmentStringLowercase hasPrefix:@":"] && !literalSearch) - { - NSString *keyword = [segmentStringLowercase substringFromIndex:1]; - - if ((keyword = [OCQueryCondition normalizeKeyword:keyword]) != nil) - { - if ([keyword isEqual:@"folder"]) - { - return ([OCQueryCondition negating:negateCondition condition:[OCQueryCondition where:OCItemPropertyNameType isEqualTo:@(OCItemTypeCollection)]]); - } - else if ([keyword isEqual:@"file"]) - { - return ([OCQueryCondition negating:negateCondition condition:[OCQueryCondition where:OCItemPropertyNameType isEqualTo:@(OCItemTypeFile)]]); - } - else if ([keyword isEqual:@"image"]) - { - return ([OCQueryCondition negating:negateCondition condition:[OCQueryCondition where:OCItemPropertyNameMIMEType startsWith:@"image/"]]); - } - else if ([keyword isEqual:@"video"]) - { - return ([OCQueryCondition negating:negateCondition condition:[OCQueryCondition where:OCItemPropertyNameMIMEType startsWith:@"video/"]]); - } - else if ([keyword isEqual:@"today"]) - { - return ([OCQueryCondition negating:negateCondition condition:[OCQueryCondition where:OCItemPropertyNameLastModified isGreaterThan:[NSDate startOfRelativeDay:0]]]); - } - else if ([keyword isEqual:@"week"]) - { - return ([OCQueryCondition negating:negateCondition condition:[OCQueryCondition where:OCItemPropertyNameLastModified isGreaterThan:[NSDate startOfRelativeWeek:0]]]); - } - else if ([keyword isEqual:@"month"]) - { - return ([OCQueryCondition negating:negateCondition condition:[OCQueryCondition where:OCItemPropertyNameLastModified isGreaterThan:[NSDate startOfRelativeMonth:0]]]); - } - else if ([keyword isEqual:@"year"]) - { - return ([OCQueryCondition negating:negateCondition condition:[OCQueryCondition where:OCItemPropertyNameLastModified isGreaterThan:[NSDate startOfRelativeYear:0]]]); - } - } - } - - if ([segmentStringLowercase containsString:@":"] && !literalSearch) - { - NSArray *parts = [segmentString componentsSeparatedByString:@":"]; - NSString *modifier = nil; - - if ((modifier = parts.firstObject.lowercaseString) != nil) - { - NSArray *parameters = [[segmentString substringFromIndex:modifier.length+1] componentsSeparatedByString:@","]; - NSMutableArray *orConditions = [NSMutableArray new]; - NSString *modifierKeyword; - - if ((modifierKeyword = [OCQueryCondition normalizeKeyword:modifier]) != nil) - { - for (NSString *parameter in parameters) - { - if (parameter.length > 0) - { - OCQueryCondition *condition = nil; - - if ([modifierKeyword isEqual:@"type"]) - { - condition = [OCQueryCondition where:OCItemPropertyNameName endsWith:[@"." stringByAppendingString:parameter]]; - } - else if ([modifierKeyword isEqual:@"after"]) - { - NSDate *afterDate; - - if ((afterDate = [NSDate dateFromKeywordString:parameter]) != nil) - { - condition = [OCQueryCondition where:OCItemPropertyNameLastModified isGreaterThan:afterDate]; - } - } - else if ([modifierKeyword isEqual:@"before"]) - { - NSDate *beforeDate; - - if ((beforeDate = [NSDate dateFromKeywordString:parameter]) != nil) - { - condition = [OCQueryCondition where:OCItemPropertyNameLastModified isLessThan:beforeDate]; - } - } - else if ([modifierKeyword isEqual:@"on"]) - { - NSDate *onStartDate = nil, *onEndDate = nil; - - if ((onStartDate = [NSDate dateFromKeywordString:parameter]) != nil) - { - onStartDate = [onStartDate dateByAddingTimeInterval:-1]; - onEndDate = [onStartDate dateByAddingTimeInterval:60*60*24+2]; - - condition = [OCQueryCondition require:@[ - [OCQueryCondition where:OCItemPropertyNameLastModified isGreaterThan:onStartDate], - [OCQueryCondition where:OCItemPropertyNameLastModified isLessThan:onEndDate] - ]]; - } - } - else if ([modifierKeyword isEqual:@"smaller"]) - { - NSNumber *byteCount = [parameter byteCountNumber]; - - if (byteCount != nil) - { - condition = [OCQueryCondition where:OCItemPropertyNameSize isLessThan:byteCount]; - } - } - else if ([modifierKeyword isEqual:@"greater"]) - { - NSNumber *byteCount = [parameter byteCountNumber]; - - if (byteCount != nil) - { - condition = [OCQueryCondition where:OCItemPropertyNameSize isGreaterThan:byteCount]; - } - } - else if ([modifierKeyword isEqual:@"owner"]) - { - condition = [OCQueryCondition where:OCItemPropertyNameOwnerUserName startsWith:parameter]; - } - else if ([modifier isEqual:@""]) - { - // Parse time formats, f.ex.: 7d, 2w, 1m, 2y - NSString *numString = nil; - - if ((parameter.length == 1) || // :d :w :m :y - ((parameter.length > 1) && // :7d :2w :1m :2y - ((numString = [parameter substringToIndex:parameter.length-1]) != nil) && - [@([numString integerValue]).stringValue isEqual:numString] - ) - ) - { - NSInteger numParam = numString.integerValue; - NSString *timeLabel = [parameter substringFromIndex:parameter.length-1].lowercaseString; - - timeLabel = [OCQueryCondition normalizeKeyword:timeLabel]; - - if ([timeLabel isEqual:@"d"]) - { - condition = [OCQueryCondition where:OCItemPropertyNameLastModified isGreaterThan:[NSDate startOfRelativeDay:-numParam]]; - } - else if ([timeLabel isEqual:@"w"]) - { - condition = [OCQueryCondition where:OCItemPropertyNameLastModified isGreaterThan:[NSDate startOfRelativeWeek:-numParam]]; - } - else if ([timeLabel isEqual:@"m"]) - { - condition = [OCQueryCondition where:OCItemPropertyNameLastModified isGreaterThan:[NSDate startOfRelativeMonth:-numParam]]; - } - else if ([timeLabel isEqual:@"y"]) - { - condition = [OCQueryCondition where:OCItemPropertyNameLastModified isGreaterThan:[NSDate startOfRelativeYear:-numParam]]; - } - } - } - - if (condition != nil) - { - [orConditions addObject:condition]; - } - } - } - - if (orConditions.count == 1) - { - return ([OCQueryCondition negating:negateCondition condition:orConditions.firstObject]); - } - else if (orConditions.count > 0) - { - return ([OCQueryCondition negating:negateCondition condition:[OCQueryCondition anyOf:orConditions]]); - } - else - { - if ([modifierKeyword isEqual:@"type"] || - [modifierKeyword isEqual:@"after"] || - [modifierKeyword isEqual:@"before"] || - [modifierKeyword isEqual:@"on"] || - [modifierKeyword isEqual:@"greater"] || - [modifierKeyword isEqual:@"smaller"] || - [modifierKeyword isEqual:@"owner"] - ) - { - // Modifiers without parameters - return (nil); - } - } - } - } - } - - return ([OCQueryCondition negating:negateCondition condition:[OCQueryCondition where:OCItemPropertyNameName contains:segmentString]]); -} - -+ (instancetype)fromSearchTerm:(NSString *)searchTerm -{ - NSArray *segments = [searchTerm segmentedForSearchWithQuotationMarks:YES]; - NSMutableArray *conditions = [NSMutableArray new]; - OCQueryCondition *queryCondition = nil; - - for (NSString *segment in segments) - { - OCQueryCondition *condition; - - if ((condition = [self forSearchSegment:segment]) != nil) - { - [conditions addObject:condition]; - } - } - - if (conditions.count == 1) - { - queryCondition = conditions.firstObject; - } - else if (conditions.count > 0) - { - queryCondition = [OCQueryCondition require:conditions]; - } - - return (queryCondition); -} - -@end diff --git a/ownCloudAppFramework/Search/OCQueryCondition+SearchSegmenter.h b/ownCloudAppFramework/Search/OCQueryCondition+SearchSegmenter.h new file mode 100644 index 000000000..1cdcc802f --- /dev/null +++ b/ownCloudAppFramework/Search/OCQueryCondition+SearchSegmenter.h @@ -0,0 +1,54 @@ +// +// OCQueryCondition+SearchSegmenter.h +// ownCloudApp +// +// Created by Felix Schwarz on 19.03.21. +// Copyright © 2021 ownCloud GmbH. All rights reserved. +// + +/* + * Copyright (C) 2021, ownCloud GmbH. + * + * This code is covered by the GNU Public License Version 3. + * + * For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ + * You should have received a copy of this license along with this program. If not, see . + * + */ + +#import +#import "OCSearchSegment.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface NSString (SearchSegmenter) + +- (NSArray *)segmentedForSearchWithQuotationMarks:(BOOL)withQuotationMarks cursorPosition:(nullable NSNumber *)inCursorPosition; +- (NSArray *)segmentedForSearchWithQuotationMarks:(BOOL)withQuotationMarks; + +@end + +@interface OCQueryCondition (SearchSegmenter) + ++ (nullable instancetype)forSearchSegment:(NSString *)segmentString; ++ (nullable instancetype)fromSearchTerm:(NSString *)searchTerm; + +@end + +@interface OCQueryCondition (SearchSegmentDescription) + +@property(strong,nonatomic,nullable) NSString *symbolName; //!< Optional, name of symbol to use +@property(strong,nonatomic,nullable) NSString *localizedDescription; //!< Optional, localized description +@property(strong,nonatomic,nullable) NSString *searchSegment; //!< Optional, search segment from which this condition was created + +@property(readonly,strong,nullable) NSString *composedSearchTerm; //!< Composes/reassembles a search term from OCQueryConditions returned from the OCQueryCondition-SearchSegmenter. Useful for persisting a query in readable form, allowing to retain its dynamic elements. (f.ex. when converting :today to an OCQueryCondition, it will always contain the day's date as reference point. Converting the term on another day will use a different date (that day's "today") in the converted query condition.) + +- (instancetype)withSymbolName:(nullable NSString *)symbolName localizedDescription:(nullable NSString *)localizedDescription searchSegment:(nullable NSString *)searchSegment; + +@end + +extern OCQueryConditionUserInfoKey OCQueryConditionUserInfoKeySymbolName; +extern OCQueryConditionUserInfoKey OCQueryConditionUserInfoKeyLocalizedDescription; +extern OCQueryConditionUserInfoKey OCQueryConditionUserInfoKeySearchSegment; + +NS_ASSUME_NONNULL_END diff --git a/ownCloudAppFramework/Search/OCQueryCondition+SearchSegmenter.m b/ownCloudAppFramework/Search/OCQueryCondition+SearchSegmenter.m new file mode 100644 index 000000000..b2268690a --- /dev/null +++ b/ownCloudAppFramework/Search/OCQueryCondition+SearchSegmenter.m @@ -0,0 +1,726 @@ +// +// OCQueryCondition+SearchSegmenter.m +// ownCloudApp +// +// Created by Felix Schwarz on 19.03.21. +// Copyright © 2021 ownCloud GmbH. All rights reserved. +// + +/* + * Copyright (C) 2021, ownCloud GmbH. + * + * This code is covered by the GNU Public License Version 3. + * + * For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ + * You should have received a copy of this license along with this program. If not, see . + * + */ + +#import "OCQueryCondition+SearchSegmenter.h" +#import "NSDate+ComputedTimes.h" +#import "NSString+ByteCountParser.h" +#import "OCLicenseManager.h" // needed as localization "anchor" + +@implementation NSString (SearchSegmenter) + +- (BOOL)isQuotationMark +{ + return ([@"“”‘‛‟„‚'\"′″´˝❛❜❝❞" containsString:self]); +} + +- (BOOL)hasQuotationMarkSuffix +{ + if (self.length > 0) + { + return ([[self substringWithRange:NSMakeRange(self.length-1, 1)] isQuotationMark]); + } + + return (NO); +} + +- (BOOL)hasQuotationMarkPrefix +{ + if (self.length > 0) + { + return ([[self substringWithRange:NSMakeRange(0, 1)] isQuotationMark]); + } + + return (NO); +} + +- (NSArray *)segmentedForSearchWithQuotationMarks:(BOOL)withQuotationMarks cursorPosition:(NSNumber *)inCursorPosition +{ + NSMutableArray *searchSegments = [NSMutableArray new]; + NSArray *terms; + + if ((terms = [self componentsSeparatedByString:@" "]) != nil) + { + __block NSString *segmentString = nil; + __block BOOL segmentOpen = NO; + __block BOOL isNegated = NO; + __block NSRange termRange = NSMakeRange(0, 0); + NSUInteger termOffset = 0; + + void (^SubmitSegment)(void) = ^{ + if (segmentString.length > 0) + { + OCSearchSegment *segment = [OCSearchSegment new]; + + segment.range = termRange; + segment.originalString = [self substringWithRange:termRange]; + segment.cursorOffset = -1; + + if (inCursorPosition != nil) + { + NSUInteger cursorPosition = inCursorPosition.unsignedIntegerValue; + + if ((cursorPosition > termRange.location) && (cursorPosition <= (termRange.location + termRange.length))) + { + segment.hasCursor = YES; + segment.cursorOffset = cursorPosition - termRange.location; + } + } + + if (segmentOpen && withQuotationMarks) + { + segment.segmentedString = [NSString stringWithFormat:@"%@\"%@\"", (isNegated ? @"-" : @""), segmentString]; + } + else + { + segment.segmentedString = isNegated ? [@"-" stringByAppendingString:segmentString] : segmentString; + } + + [searchSegments addObject:segment]; + } + + segmentString = nil; + }; + + for (NSString *inTerm in terms) + { + NSString *term = inTerm; + BOOL closingSegment = NO; + + termRange.location += termOffset; + termRange.length = inTerm.length; + + if (!segmentOpen) + { + isNegated = NO; + } + + if ([term hasPrefix:@"-"]) + { + // Negate segment + isNegated = YES; + term = [term substringFromIndex:1]; + } + + if ([term hasQuotationMarkPrefix]) + { + // Submit any open segment + SubmitSegment(); + + // Start new segment + term = [term substringFromIndex:1]; + segmentOpen = YES; + } + + if ([term hasQuotationMarkSuffix]) + { + // End segment + term = [term substringToIndex:term.length-1]; + closingSegment = YES; + } + + // Append term to current segment + if (segmentString.length == 0) + { + segmentString = term; + + if (!segmentOpen) + { + // Submit standalone segment + SubmitSegment(); + } + } + else + { + // Append to segment string + termRange.location -= (segmentString.length + 1); + termRange.length += (segmentString.length + 1); + segmentString = [segmentString stringByAppendingFormat:@" %@", term]; + } + + // Submit closed segments + if (closingSegment) + { + SubmitSegment(); + segmentOpen = NO; + } + + termOffset = termRange.length + 1; + } + + SubmitSegment(); + } + + return (searchSegments); +} + +- (NSArray *)segmentedForSearchWithQuotationMarks:(BOOL)withQuotationMarks +{ + NSArray *searchSegments = [self segmentedForSearchWithQuotationMarks:withQuotationMarks cursorPosition:nil]; + + return ([searchSegments arrayUsingMapper:^NSString* _Nullable(OCSearchSegment* _Nonnull segment) { + return (segment.segmentedString); + }]); +} + +@end + +@implementation OCQueryCondition (SearchSegmenter) + ++ (nullable NSString *)normalizeKeyword:(NSString *)keyword +{ + static dispatch_once_t onceToken; + static NSArray *keywords; + static NSDictionary *keywordByLocalizedKeyword; + + dispatch_once(&onceToken, ^{ + NSBundle *localizationBundle = [NSBundle bundleForClass:OCLicenseManager.class]; + + #define TranslateKeyword(keyword) [[localizationBundle localizedStringForKey:@"keyword_" keyword value:keyword table:@"Localizable"] lowercaseString] : keyword + + keywordByLocalizedKeyword = @{ + // Standalone keywords + TranslateKeyword(@"file"), + TranslateKeyword(@"folder"), + TranslateKeyword(@"image"), + TranslateKeyword(@"video"), + TranslateKeyword(@"audio"), + TranslateKeyword(@"document"), + TranslateKeyword(@"spreadsheet"), + TranslateKeyword(@"presentation"), + TranslateKeyword(@"pdf"), + TranslateKeyword(@"today"), + TranslateKeyword(@"week"), + TranslateKeyword(@"month"), + TranslateKeyword(@"year"), + + // Modifier keywords + TranslateKeyword(@"type"), + TranslateKeyword(@"after"), + TranslateKeyword(@"before"), + TranslateKeyword(@"on"), + TranslateKeyword(@"smaller"), + TranslateKeyword(@"greater"), + TranslateKeyword(@"owner"), + + // Suffix keywords + TranslateKeyword(@"d"), + TranslateKeyword(@"w"), + TranslateKeyword(@"m"), + TranslateKeyword(@"y") + }; + + keywords = [keywordByLocalizedKeyword allValues]; + }); + + NSString *normalizedKeyword = nil; + + if (keyword != nil) + { + keyword = [keyword lowercaseString]; + + if ((normalizedKeyword = keywordByLocalizedKeyword[keyword]) == nil) + { + if ([keywords containsObject:keyword]) + { + normalizedKeyword = keyword; + } + } + } + + if ((normalizedKeyword == nil) && (keyword.length == 0)) + { + normalizedKeyword = keyword; + } + + return (normalizedKeyword); +} + ++ (instancetype)forSearchSegment:(NSString *)segmentString +{ + NSString *segmentStringLowercase = nil; + NSString *searchSegment = segmentString; + BOOL negateCondition = NO; + BOOL literalSearch = NO; + + if ([segmentString hasPrefix:@"-"]) + { + negateCondition = YES; + segmentString = [segmentString substringFromIndex:1]; + } + + if ([segmentString hasPrefix:@"\""] && [segmentString hasSuffix:@"\""] && (segmentString.length >= 2)) + { + literalSearch = YES; + segmentString = [segmentString substringWithRange:NSMakeRange(1, segmentString.length-2)]; + } + + if (segmentString.length == 0) + { + return (nil); + } + + segmentStringLowercase = segmentString.lowercaseString; + + if ([segmentStringLowercase hasPrefix:@":"] && !literalSearch) + { + NSString *keyword = [segmentStringLowercase substringFromIndex:1]; + + if ((keyword = [OCQueryCondition normalizeKeyword:keyword]) != nil) + { + if ([keyword isEqual:@"folder"]) + { + return ([[OCQueryCondition negating:negateCondition condition:[OCQueryCondition where:OCItemPropertyNameType isEqualTo:@(OCItemTypeCollection)]] withSymbolName:@"folder" localizedDescription:(negateCondition ? OCLocalized(@"No folder") : OCLocalized(@"Folder")) searchSegment:searchSegment]); + } + else if ([keyword isEqual:@"file"]) + { + return ([[OCQueryCondition negating:negateCondition condition:[OCQueryCondition where:OCItemPropertyNameType isEqualTo:@(OCItemTypeFile)]] withSymbolName:@"doc" localizedDescription:(negateCondition ? OCLocalized(@"No file") : OCLocalized(@"File")) searchSegment:searchSegment]); + } + else if ([keyword isEqual:@"image"]) + { + return ([[OCQueryCondition negating:negateCondition condition:[OCQueryCondition anyOf:@[ + [OCQueryCondition where:OCItemPropertyNameMIMEType startsWith:@"image/"], + [OCQueryCondition where:OCItemPropertyNameTypeAlias isEqualTo:@"image"], + ]]] withSymbolName:@"photo" localizedDescription:(negateCondition ? OCLocalized(@"No image") : OCLocalized(@"Image")) searchSegment:searchSegment]); + } + else if ([keyword isEqual:@"video"]) + { + return ([[OCQueryCondition negating:negateCondition condition:[OCQueryCondition where:OCItemPropertyNameMIMEType startsWith:@"video/"]] withSymbolName:@"film" localizedDescription:(negateCondition ? OCLocalized(@"No video") : OCLocalized(@"Video")) searchSegment:searchSegment]); + } + else if ([keyword isEqual:@"audio"]) + { + return ([[OCQueryCondition negating:negateCondition condition:[OCQueryCondition where:OCItemPropertyNameMIMEType startsWith:@"audio/"]] withSymbolName:@"waveform" localizedDescription:(negateCondition ? OCLocalized(@"No audio") : OCLocalized(@"Audio")) searchSegment:searchSegment]); + } + else if ([keyword isEqual:@"document"]) + { + return ([[OCQueryCondition negating:negateCondition condition:[OCQueryCondition where:OCItemPropertyNameTypeAlias isEqualTo:@"x-office/document"]] withSymbolName:@"doc.text" localizedDescription:(negateCondition ? OCLocalized(@"No document") : OCLocalized(@"Document")) searchSegment:searchSegment]); + } + else if ([keyword isEqual:@"spreadsheet"]) + { + return ([[OCQueryCondition negating:negateCondition condition:[OCQueryCondition where:OCItemPropertyNameTypeAlias startsWith:@"x-office/spreadsheet"]] withSymbolName:@"tablecells" localizedDescription:(negateCondition ? OCLocalized(@"No spreadsheet") : OCLocalized(@"Spreadsheet")) searchSegment:searchSegment]); + } + else if ([keyword isEqual:@"presentation"]) + { + return ([[OCQueryCondition negating:negateCondition condition:[OCQueryCondition where:OCItemPropertyNameTypeAlias startsWith:@"x-office/presentation"]] withSymbolName:@"chart.pie" localizedDescription:(negateCondition ? OCLocalized(@"No presentation") : OCLocalized(@"Presentation")) searchSegment:searchSegment]); + } + else if ([keyword isEqual:@"pdf"]) + { + return ([[OCQueryCondition negating:negateCondition condition:[OCQueryCondition where:OCItemPropertyNameMIMEType isEqualTo:@"application/pdf"]] withSymbolName:@"doc.richtext" localizedDescription:(negateCondition ? OCLocalized(@"No PDF") : OCLocalized(@"PDF")) searchSegment:searchSegment]); + } + else if ([keyword isEqual:@"today"]) + { + return ([[OCQueryCondition negating:negateCondition condition:[OCQueryCondition where:OCItemPropertyNameLastModified isGreaterThan:[NSDate startOfRelativeDay:0]]] withSymbolName:@"calendar" localizedDescription:(negateCondition ? OCLocalized(@"Before today") : OCLocalized(@"Today")) searchSegment:searchSegment]); + } + else if ([keyword isEqual:@"week"]) + { + return ([[OCQueryCondition negating:negateCondition condition:[OCQueryCondition where:OCItemPropertyNameLastModified isGreaterThan:[NSDate startOfRelativeWeek:0]]] withSymbolName:@"calendar" localizedDescription:(negateCondition ? OCLocalized(@"Before this week") : OCLocalized(@"This week")) searchSegment:searchSegment]); + } + else if ([keyword isEqual:@"month"]) + { + return ([[OCQueryCondition negating:negateCondition condition:[OCQueryCondition where:OCItemPropertyNameLastModified isGreaterThan:[NSDate startOfRelativeMonth:0]]] withSymbolName:@"calendar" localizedDescription:(negateCondition ? OCLocalized(@"Before this month") : OCLocalized(@"This month")) searchSegment:searchSegment]); + } + else if ([keyword isEqual:@"year"]) + { + return ([[OCQueryCondition negating:negateCondition condition:[OCQueryCondition where:OCItemPropertyNameLastModified isGreaterThan:[NSDate startOfRelativeYear:0]]] withSymbolName:@"calendar" localizedDescription:(negateCondition ? OCLocalized(@"Before this year") : OCLocalized(@"This year")) searchSegment:searchSegment]); + } + } + } + + if ([segmentStringLowercase containsString:@":"] && !literalSearch) + { + NSArray *parts = [segmentString componentsSeparatedByString:@":"]; + NSString *modifier = nil; + + if ((modifier = parts.firstObject.lowercaseString) != nil) + { + NSArray *parameters = [[segmentString substringFromIndex:modifier.length+1] componentsSeparatedByString:@","]; + NSMutableArray *orConditions = [NSMutableArray new]; + NSString *modifierKeyword; + + if ((modifierKeyword = [OCQueryCondition normalizeKeyword:modifier]) != nil) + { + __block NSString *symbolName = nil; + __block NSString *localizedStart = nil; + __block NSString *localizedParameterDescriptions = nil; + + void (^ComposeAndSetDescription)(OCQueryCondition *condition, NSString *symName, NSString *descStart, NSString *paramDesc) = ^(OCQueryCondition *condition, NSString *symName, NSString *descStart, NSString *paramDesc) { + condition.symbolName = symbolName; + condition.localizedDescription = [NSString stringWithFormat:@"%@%@%@", ((descStart != nil) ? descStart : @""), (((descStart != nil) && (paramDesc != nil)) ? @" " : @""), ((paramDesc != nil) ? paramDesc : @"")]; + }; + + void (^AddDescription)(OCQueryCondition *condition, NSString *symName, NSString *descStart, NSString *paramDesc) = ^(OCQueryCondition *condition, NSString *symName, NSString *descStart, NSString *paramDesc) { + symbolName = symName; + localizedStart = descStart; + + if (localizedParameterDescriptions == nil) { + localizedParameterDescriptions = paramDesc; + } else { + localizedParameterDescriptions = [NSString stringWithFormat:@"%@, %@", localizedParameterDescriptions, paramDesc]; + } + + ComposeAndSetDescription(condition, symName, descStart, paramDesc); + }; + + for (NSString *parameter in parameters) + { + if (parameter.length > 0) + { + OCQueryCondition *condition = nil; + + if ([modifierKeyword isEqual:@"type"]) + { + condition = [OCQueryCondition where:OCItemPropertyNameName endsWith:[@"." stringByAppendingString:parameter]]; + AddDescription(condition, @"circlebadge.fill", nil, parameter); + } + else if ([modifierKeyword isEqual:@"after"]) + { + NSDate *afterDate; + + if ((afterDate = [NSDate dateFromKeywordString:parameter]) != nil) + { + condition = [OCQueryCondition where:OCItemPropertyNameLastModified isGreaterThan:afterDate]; + AddDescription(condition, @"calendar", negateCondition ? OCLocalized(@"Before") : OCLocalized(@"After"), [afterDate localizedStringWithTemplate:@"MMM d, yy" locale:nil]); + } + } + else if ([modifierKeyword isEqual:@"before"]) + { + NSDate *beforeDate; + + if ((beforeDate = [NSDate dateFromKeywordString:parameter]) != nil) + { + condition = [OCQueryCondition where:OCItemPropertyNameLastModified isLessThan:beforeDate]; + AddDescription(condition, @"calendar", negateCondition ? OCLocalized(@"After") : OCLocalized(@"Before"), [beforeDate localizedStringWithTemplate:@"MMM d, yy" locale:nil]); + } + } + else if ([modifierKeyword isEqual:@"on"]) + { + NSDate *onStartDate = nil, *onEndDate = nil; + + if ((onStartDate = [NSDate dateFromKeywordString:parameter]) != nil) + { + onStartDate = [onStartDate dateByAddingTimeInterval:-1]; + onEndDate = [onStartDate dateByAddingTimeInterval:60*60*24+2]; + + condition = [OCQueryCondition require:@[ + [OCQueryCondition where:OCItemPropertyNameLastModified isGreaterThan:onStartDate], + [OCQueryCondition where:OCItemPropertyNameLastModified isLessThan:onEndDate] + ]]; + AddDescription(condition, @"calendar", negateCondition ? OCLocalized(@"Not on") : OCLocalized(@"On"), [onStartDate localizedStringWithTemplate:@"MMM d, yy" locale:nil]); + } + } + else if ([modifierKeyword isEqual:@"smaller"]) + { + NSNumber *byteCount = [parameter byteCountNumber]; + + if (byteCount != nil) + { + condition = [OCQueryCondition where:OCItemPropertyNameSize isLessThan:byteCount]; + AddDescription(condition, @"ruler", nil, [(negateCondition ? @"> " : @"< ") stringByAppendingString:[NSByteCountFormatter stringFromByteCount:byteCount.longLongValue countStyle:NSByteCountFormatterCountStyleFile]]); + } + } + else if ([modifierKeyword isEqual:@"greater"]) + { + NSNumber *byteCount = [parameter byteCountNumber]; + + if (byteCount != nil) + { + condition = [OCQueryCondition where:OCItemPropertyNameSize isGreaterThan:byteCount]; + AddDescription(condition, @"ruler", nil, [(negateCondition ? @"< " : @"> ") stringByAppendingString:[NSByteCountFormatter stringFromByteCount:byteCount.longLongValue countStyle:NSByteCountFormatterCountStyleFile]]); + } + } + else if ([modifierKeyword isEqual:@"owner"]) + { + condition = [OCQueryCondition where:OCItemPropertyNameOwnerUserName startsWith:parameter]; + AddDescription(condition, @"person.crop.circle", nil, negateCondition ? [OCLocalized(@"Not") stringByAppendingFormat:@" %@", parameter] : parameter); + } + else if ([modifier isEqual:@""]) + { + // Parse time formats, f.ex.: 7d, 2w, 1m, 2y + NSString *numString = nil; + + if ((parameter.length == 1) || // :d :w :m :y + ((parameter.length > 1) && // :7d :2w :1m :2y + ((numString = [parameter substringToIndex:parameter.length-1]) != nil) && + [@([numString integerValue]).stringValue isEqual:numString] + ) + ) + { + NSInteger numParam = numString.integerValue; + NSString *timeLabel = [parameter substringFromIndex:parameter.length-1].lowercaseString; + NSString *localizedDescription = nil; + NSDate *greaterThanDate = nil; + + timeLabel = [OCQueryCondition normalizeKeyword:timeLabel]; + + if ([timeLabel isEqual:@"d"]) + { + greaterThanDate = [NSDate startOfRelativeDay:-numParam]; + if (negateCondition) + { + localizedDescription = (numParam == 0) ? OCLocalized(@"Before today") : ((numParam == 1) ? OCLocalized(@"Before yesterday") : [NSString stringWithFormat:OCLocalized(@">%d days ago"), numParam]); + } + else + { + localizedDescription = (numParam == 0) ? OCLocalized(@"Today") : ((numParam == 1) ? OCLocalized(@"Since yesterday") : [NSString stringWithFormat:OCLocalized(@"Last %d days"), numParam]); + } + } + else if ([timeLabel isEqual:@"w"]) + { + greaterThanDate = [NSDate startOfRelativeWeek:-numParam]; + if (negateCondition) + { + localizedDescription = (numParam == 0) ? OCLocalized(@"Before this week") : ((numParam == 1) ? OCLocalized(@"Before last week") : [NSString stringWithFormat:OCLocalized(@">%d weeks ago"), numParam]); + } + else + { + localizedDescription = (numParam == 0) ? OCLocalized(@"This week") : ((numParam == 1) ? OCLocalized(@"Since last week") : [NSString stringWithFormat:OCLocalized(@"Last %d weeks"), numParam]); + } + } + else if ([timeLabel isEqual:@"m"]) + { + greaterThanDate = [NSDate startOfRelativeMonth:-numParam]; + if (negateCondition) + { + localizedDescription = (numParam == 0) ? OCLocalized(@"Before this month") : ((numParam == 1) ? OCLocalized(@"Before last month") : [NSString stringWithFormat:OCLocalized(@"> %d months ago"), numParam]); + } + else + { + localizedDescription = (numParam == 0) ? OCLocalized(@"This month") : ((numParam == 1) ? OCLocalized(@"Since last month") : [NSString stringWithFormat:OCLocalized(@"Last %d months"), numParam]); + } + } + else if ([timeLabel isEqual:@"y"]) + { + greaterThanDate = [NSDate startOfRelativeYear:-numParam]; + if (negateCondition) + { + localizedDescription = (numParam == 0) ? OCLocalized(@"Before this year") : ((numParam == 1) ? OCLocalized(@"Before last year") : [NSString stringWithFormat:OCLocalized(@"> %d years ago"), numParam]); + } + else + { + localizedDescription = (numParam == 0) ? OCLocalized(@"This year") : ((numParam == 1) ? OCLocalized(@"Since last year") : [NSString stringWithFormat:OCLocalized(@"Last %d years"), numParam]); + } + } + + if (greaterThanDate != nil) + { + condition = [[OCQueryCondition where:OCItemPropertyNameLastModified isGreaterThan:greaterThanDate] withSymbolName:@"calendar" localizedDescription:localizedDescription searchSegment:nil]; + } + } + } + + if (condition != nil) + { + [orConditions addObject:condition]; + } + } + } + + if (orConditions.count == 1) + { + OCQueryCondition *composedCondition = [OCQueryCondition negating:negateCondition condition:orConditions.firstObject]; + + composedCondition.searchSegment = searchSegment; + return (composedCondition); + } + else if (orConditions.count > 0) + { + OCQueryCondition *composedCondition = [OCQueryCondition negating:negateCondition condition:[OCQueryCondition anyOf:orConditions]]; + + ComposeAndSetDescription(composedCondition, symbolName, localizedStart, localizedParameterDescriptions); + + composedCondition.searchSegment = searchSegment; + return (composedCondition); + } + else + { + if ([modifierKeyword isEqual:@"type"] || + [modifierKeyword isEqual:@"after"] || + [modifierKeyword isEqual:@"before"] || + [modifierKeyword isEqual:@"on"] || + [modifierKeyword isEqual:@"greater"] || + [modifierKeyword isEqual:@"smaller"] || + [modifierKeyword isEqual:@"owner"] + ) + { + // Modifiers without parameters + return (nil); + } + } + } + } + } + + OCQueryCondition *nameCondition = [OCQueryCondition negating:negateCondition condition:[OCQueryCondition where:OCItemPropertyNameName contains:segmentString]]; + nameCondition.searchSegment = searchSegment; + return (nameCondition); +} + ++ (instancetype)fromSearchTerm:(NSString *)searchTerm +{ + NSArray *segments = [searchTerm segmentedForSearchWithQuotationMarks:YES]; + NSMutableArray *conditions = [NSMutableArray new]; + OCQueryCondition *queryCondition = nil; + + for (NSString *segment in segments) + { + OCQueryCondition *condition; + + if ((condition = [self forSearchSegment:segment]) != nil) + { + [conditions addObject:condition]; + } + } + + if (conditions.count == 1) + { + queryCondition = conditions.firstObject; + } + else if (conditions.count > 0) + { + queryCondition = [OCQueryCondition require:conditions]; + } + + return (queryCondition); +} + +@end + +@implementation OCQueryCondition (SearchSegmentDescription) + +- (void)setValue:(id)value forMutableUserInfoKey:(OCQueryConditionUserInfoKey)key +{ + NSMutableDictionary *userInfo = nil; + + if ((userInfo = (NSMutableDictionary *)self.userInfo) != nil) + { + if (![userInfo isKindOfClass:NSMutableDictionary.class]) + { + userInfo = [[NSMutableDictionary alloc] initWithDictionary:userInfo]; + } + } + else + { + userInfo = [NSMutableDictionary new]; + } + + userInfo[key] = value; + + self.userInfo = userInfo; + +} + +- (NSString *)symbolName +{ + return (self.userInfo[OCQueryConditionUserInfoKeySymbolName]); +} + +- (void)setSymbolName:(NSString *)symbolName +{ + [self setValue:symbolName forMutableUserInfoKey:OCQueryConditionUserInfoKeySymbolName]; +} + +- (NSString *)localizedDescription +{ + return (self.userInfo[OCQueryConditionUserInfoKeyLocalizedDescription]); +} + +- (void)setLocalizedDescription:(NSString *)localizedDescription +{ + [self setValue:localizedDescription forMutableUserInfoKey:OCQueryConditionUserInfoKeyLocalizedDescription]; +} + +- (NSString *)searchSegment +{ + return (self.userInfo[OCQueryConditionUserInfoKeySearchSegment]); +} + +- (void)setSearchSegment:(NSString *)searchSegment +{ + [self setValue:searchSegment forMutableUserInfoKey:OCQueryConditionUserInfoKeySearchSegment]; +} + +- (void)_addToComposedSearchTerm:(NSMutableString *)composedSearchTerm +{ + NSString *searchSegment = self.searchSegment; + + if (searchSegment.length > 0) + { + if (composedSearchTerm.length > 0) + { + [composedSearchTerm appendFormat:@" %@", searchSegment]; + } + else + { + [composedSearchTerm appendString:searchSegment]; + } + } + + switch (self.operator) + { + case OCQueryConditionOperatorNegate: + case OCQueryConditionOperatorAnd: + case OCQueryConditionOperatorOr: { + OCQueryCondition *containedCondition; + NSArray *containedConditions; + + if ((containedCondition = OCTypedCast(self.value, OCQueryCondition)) != nil) + { + [containedCondition _addToComposedSearchTerm:composedSearchTerm]; + } + else if ((containedConditions = OCTypedCast(self.value, NSArray)) != nil) + { + for (OCQueryCondition *condition in containedConditions) + { + [condition _addToComposedSearchTerm:composedSearchTerm]; + } + } + } + break; + + default: + break; + } +} + +- (NSString *)composedSearchTerm +{ + NSMutableString *composedSearchTerm = [NSMutableString new]; + + [self _addToComposedSearchTerm:composedSearchTerm]; + + if (composedSearchTerm.length > 0) + { + return (composedSearchTerm); + } + + return(nil); +} + +- (instancetype)withSymbolName:(NSString *)symbolName localizedDescription:(NSString *)localizedDescription searchSegment:(NSString *)searchSegment +{ + self.symbolName = symbolName; + self.localizedDescription = localizedDescription; + self.searchSegment = searchSegment; + + return (self); +} + +@end + +OCQueryConditionUserInfoKey OCQueryConditionUserInfoKeySymbolName = @"symbolName"; +OCQueryConditionUserInfoKey OCQueryConditionUserInfoKeyLocalizedDescription = @"localizedDescription"; +OCQueryConditionUserInfoKey OCQueryConditionUserInfoKeySearchSegment = @"searchSegment"; diff --git a/ownCloudAppFramework/Search/OCSearchSegment.h b/ownCloudAppFramework/Search/OCSearchSegment.h new file mode 100644 index 000000000..877aed365 --- /dev/null +++ b/ownCloudAppFramework/Search/OCSearchSegment.h @@ -0,0 +1,35 @@ +// +// OCSearchSegment.h +// ownCloudApp +// +// Created by Felix Schwarz on 22.08.22. +// Copyright © 2022 ownCloud GmbH. All rights reserved. +// + +/* + * Copyright (C) 2022, ownCloud GmbH. + * + * This code is covered by the GNU Public License Version 3. + * + * For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ + * You should have received a copy of this license along with this program. If not, see . + * + */ + +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface OCSearchSegment : NSObject + +@property(assign) NSRange range; //!< The range of the segment within the search term it was extracted from. +@property(assign) BOOL hasCursor; //!< YES if the cursor is currently placed at the end or inside this segment. + +@property(strong) NSString *originalString; //!< Original segment string, before normalization. +@property(assign) NSInteger cursorOffset; //!< If .hasCursor is YES, the position of the cursor within the originalString. -1 otherwise. + +@property(strong) NSString *segmentedString; //!< The normalized string of this segment. + +@end + +NS_ASSUME_NONNULL_END diff --git a/ownCloudAppFramework/Search/OCSearchSegment.m b/ownCloudAppFramework/Search/OCSearchSegment.m new file mode 100644 index 000000000..26453a162 --- /dev/null +++ b/ownCloudAppFramework/Search/OCSearchSegment.m @@ -0,0 +1,36 @@ +// +// OCSearchSegment.m +// ownCloudApp +// +// Created by Felix Schwarz on 22.08.22. +// Copyright © 2022 ownCloud GmbH. All rights reserved. +// + +/* + * Copyright (C) 2022, ownCloud GmbH. + * + * This code is covered by the GNU Public License Version 3. + * + * For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ + * You should have received a copy of this license along with this program. If not, see . + * + */ + +#import "OCSearchSegment.h" +#import + +@implementation OCSearchSegment + +#pragma mark - Description +- (NSString *)description +{ + return ([NSString stringWithFormat:@"<%@: %p%@%@ hasCursor: %d, cursorOffset: %ld, range: %@>", NSStringFromClass(self.class), self, + OCExpandVar(originalString), + OCExpandVar(segmentedString), + _hasCursor, + _cursorOffset, + NSStringFromRange(_range) + ]); +} + +@end diff --git a/ownCloudAppFramework/Search/Saved Searches/OCSavedSearch.h b/ownCloudAppFramework/Search/Saved Searches/OCSavedSearch.h new file mode 100644 index 000000000..7688ce103 --- /dev/null +++ b/ownCloudAppFramework/Search/Saved Searches/OCSavedSearch.h @@ -0,0 +1,52 @@ +// +// OCSavedSearch.h +// ownCloudApp +// +// Created by Felix Schwarz on 13.09.22. +// Copyright © 2022 ownCloud GmbH. All rights reserved. +// + +/* + * Copyright (C) 2022, ownCloud GmbH. + * + * This code is covered by the GNU Public License Version 3. + * + * For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ + * You should have received a copy of this license along with this program. If not, see . + * + */ + +#import +#import + +typedef NSString* OCSavedSearchScope NS_TYPED_ENUM; +typedef NSString* OCSavedSearchUUID; +typedef NSString* OCSavedSearchUserInfoKey NS_TYPED_ENUM; + +NS_ASSUME_NONNULL_BEGIN + +@interface OCSavedSearch : NSObject + +@property(readonly,strong) OCSavedSearchUUID uuid; //!< Unique ID of the saved search + +@property(strong) OCSavedSearchScope scope; //!< The scope of the saved search + +@property(assign) BOOL isTemplate; //!< This is a search template. Search templates can be restored right inside search. They restore search terms, scope but drop the location. This allows building reusable filters. + +@property(strong,nullable) OCLocation *location; //!< The location for saved searches with folder or drive scope +@property(strong,nonatomic) NSString *name; //!< User-chosen or autogenerated name of the saved search. Falls back to .searchTerm if none was provided. +@property(readonly,nonatomic) BOOL isNameUserDefined; //!< User defined the name +@property(strong) NSString *searchTerm; //!< Search term parseable by OCQueryCondition+SearchSegmenter + +@property(strong,nullable) NSDictionary *userInfo; //!< Userinfo for richer UI presentation, only use types from OCEvent.safeClasses! + +- (instancetype)initWithScope:(OCSavedSearchScope)scope location:(nullable OCLocation *)location name:(nullable NSString *)name isTemplate:(BOOL)isTemplate searchTerm:(NSString *)searchTerm userInfo:(nullable NSDictionary *)userInfo; + +@end + +extern OCSavedSearchScope OCSavedSearchScopeFolder; +extern OCSavedSearchScope OCSavedSearchScopeContainer; +extern OCSavedSearchScope OCSavedSearchScopeDrive; +extern OCSavedSearchScope OCSavedSearchScopeAccount; + +NS_ASSUME_NONNULL_END diff --git a/ownCloudAppFramework/Search/Saved Searches/OCSavedSearch.m b/ownCloudAppFramework/Search/Saved Searches/OCSavedSearch.m new file mode 100644 index 000000000..d4cfa8f51 --- /dev/null +++ b/ownCloudAppFramework/Search/Saved Searches/OCSavedSearch.m @@ -0,0 +1,138 @@ +// +// OCSavedSearch.m +// ownCloudApp +// +// Created by Felix Schwarz on 13.09.22. +// Copyright © 2022 ownCloud GmbH. All rights reserved. +// + +/* + * Copyright (C) 2022, ownCloud GmbH. + * + * This code is covered by the GNU Public License Version 3. + * + * For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ + * You should have received a copy of this license along with this program. If not, see . + * + */ + +#import "OCSavedSearch.h" + +@implementation OCSavedSearch + +- (instancetype)initWithScope:(OCSavedSearchScope)scope location:(nullable OCLocation *)location name:(nullable NSString *)name isTemplate:(BOOL)isTemplate searchTerm:(NSString *)searchTerm userInfo:(nullable NSDictionary *)userInfo +{ + if ((self = [super init]) != nil) + { + _uuid = NSUUID.UUID.UUIDString; + _scope = scope; + _isTemplate = isTemplate; + _location = location; + _name = name; + _searchTerm = searchTerm; + _userInfo = userInfo; + } + + return (self); +} + +- (NSString *)name +{ + return ((_name != nil) ? _name : _searchTerm); +} + +- (BOOL)isNameUserDefined +{ + return (_name != nil); +} + +#pragma mark - Data item & Data item versioning +- (OCDataItemType)dataItemType +{ + return (OCDataItemTypeSavedSearch); +} + +- (OCDataItemReference)dataItemReference +{ + return (_uuid); +} + +- (OCDataItemVersion)dataItemVersion +{ + return (@(self.hash)); +} + +#pragma mark - Comparison +- (NSUInteger)hash +{ + return (_uuid.hash ^ _scope.hash ^ _location.hash ^ _name.hash ^ _searchTerm.hash ^ _userInfo.hash ^ (_isTemplate ? 0xFEA43 : 0)); +} + +- (BOOL)isEqual:(id)object +{ + OCSavedSearch *otherSavedSearch; + + if ((otherSavedSearch = OCTypedCast(object, OCSavedSearch)) != nil) + { + return (OCNAIsEqual(otherSavedSearch.uuid, _uuid) && + OCNAIsEqual(otherSavedSearch.scope, _scope) && + OCNAIsEqual(otherSavedSearch.location, _location) && + OCNAIsEqual(otherSavedSearch.name, self.name) && + OCNAIsEqual(otherSavedSearch.searchTerm, _searchTerm) && + OCNAIsEqual(otherSavedSearch.userInfo, _userInfo) && + otherSavedSearch.isTemplate == _isTemplate + ); + } + + return (NO); +} + +#pragma mark - Secure coding ++ (BOOL)supportsSecureCoding +{ + return (YES); +} + +- (void)encodeWithCoder:(nonnull NSCoder *)coder +{ + [coder encodeObject:_uuid forKey:@"uuid"]; + + [coder encodeBool:_isTemplate forKey:@"isTemplate"]; + + [coder encodeObject:_scope forKey:@"scope"]; + + [coder encodeObject:_location forKey:@"location"]; + + [coder encodeObject:_name forKey:@"name"]; + [coder encodeObject:_searchTerm forKey:@"searchTerm"]; + + [coder encodeObject:_userInfo forKey:@"userInfo"]; +} + +- (nullable instancetype)initWithCoder:(nonnull NSCoder *)coder +{ + if ((self = [self init]) != nil) + { + _uuid = [coder decodeObjectOfClass:NSString.class forKey:@"uuid"]; + + _isTemplate = [coder decodeBoolForKey:@"isTemplate"]; + + _scope = [coder decodeObjectOfClass:NSString.class forKey:@"scope"]; + + _location = [coder decodeObjectOfClass:OCLocation.class forKey:@"location"]; + + _name = [coder decodeObjectOfClass:NSString.class forKey:@"name"]; + _searchTerm = [coder decodeObjectOfClass:NSString.class forKey:@"searchTerm"]; + + _userInfo = [coder decodeObjectOfClasses:OCEvent.safeClasses forKey:@"userInfo"]; + } + + return (self); +} + +@end + +OCSavedSearchScope OCSavedSearchScopeFolder = @"folder"; +OCSavedSearchScope OCSavedSearchScopeContainer = @"container"; +OCSavedSearchScope OCSavedSearchScopeDrive = @"drive"; +OCSavedSearchScope OCSavedSearchScopeAccount = @"account"; diff --git a/ownCloudAppFramework/Search/Saved Searches/OCVault+SavedSearches.h b/ownCloudAppFramework/Search/Saved Searches/OCVault+SavedSearches.h new file mode 100644 index 000000000..466e118c7 --- /dev/null +++ b/ownCloudAppFramework/Search/Saved Searches/OCVault+SavedSearches.h @@ -0,0 +1,37 @@ +// +// OCVault+SavedSearches.h +// ownCloudApp +// +// Created by Felix Schwarz on 13.09.22. +// Copyright © 2022 ownCloud GmbH. All rights reserved. +// + +/* + * Copyright (C) 2022, ownCloud GmbH. + * + * This code is covered by the GNU Public License Version 3. + * + * For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ + * You should have received a copy of this license along with this program. If not, see . + * + */ + +#import +#import "OCSavedSearch.h" + +NS_ASSUME_NONNULL_BEGIN + +@interface OCVault (SavedSearches) + +@property(readonly,strong,nullable) NSArray *savedSearches; + +- (void)addSavedSearch:(OCSavedSearch *)savedSearch; +- (void)deleteSavedSearch:(OCSavedSearch *)savedSearch; + +- (void)addSavedSearchesObserver:(id)owner withInitial:(BOOL)initial updateHandler:(void(^)(id owner, NSArray * _Nullable savedSearches, BOOL initial))updateHandler; + +@end + +extern OCKeyValueStoreKey OCKeyValueStoreKeySavedSearches; + +NS_ASSUME_NONNULL_END diff --git a/ownCloudAppFramework/Search/Saved Searches/OCVault+SavedSearches.m b/ownCloudAppFramework/Search/Saved Searches/OCVault+SavedSearches.m new file mode 100644 index 000000000..1defd71c9 --- /dev/null +++ b/ownCloudAppFramework/Search/Saved Searches/OCVault+SavedSearches.m @@ -0,0 +1,96 @@ +// +// OCVault+SavedSearches.m +// ownCloudApp +// +// Created by Felix Schwarz on 13.09.22. +// Copyright © 2022 ownCloud GmbH. All rights reserved. +// + +/* + * Copyright (C) 2022, ownCloud GmbH. + * + * This code is covered by the GNU Public License Version 3. + * + * For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ + * You should have received a copy of this license along with this program. If not, see . + * + */ + +#import "OCVault+SavedSearches.h" + +@implementation OCVault (SavedSearches) + ++ (void)load +{ + [OCKeyValueStore registerClasses:[NSSet setWithObjects:NSArray.class, OCSavedSearch.class, nil] forKey:OCKeyValueStoreKeySavedSearches]; +} + +- (NSArray *)savedSearches +{ + NSMutableArray *savedSearches = [self.keyValueStore readObjectForKey:OCKeyValueStoreKeySavedSearches]; + + return (savedSearches); +} + +- (void)addSavedSearch:(OCSavedSearch *)savedSearch +{ + [self willChangeValueForKey:@"savedSearches"]; + + [self.keyValueStore updateObjectForKey:OCKeyValueStoreKeySavedSearches usingModifier:^id _Nullable(id _Nullable existingObject, BOOL * _Nonnull outDidModify) { + NSMutableArray *savedSearches = OCTypedCast(existingObject, NSMutableArray); + if (savedSearches == nil) + { + savedSearches = [NSMutableArray new]; + } + + [savedSearches addObject:savedSearch]; + + *outDidModify = YES; + return (savedSearches); + }]; + + [self didChangeValueForKey:@"savedSearches"]; +} + +- (void)deleteSavedSearch:(OCSavedSearch *)savedSearch +{ + [self willChangeValueForKey:@"savedSearches"]; + + [self.keyValueStore updateObjectForKey:OCKeyValueStoreKeySavedSearches usingModifier:^id _Nullable(id _Nullable existingObject, BOOL * _Nonnull outDidModify) { + NSMutableArray *savedSearches = OCTypedCast(existingObject, NSMutableArray); + NSUInteger countBefore; + + if ((savedSearches != nil) && ((countBefore = savedSearches.count) > 0)) + { + [savedSearches removeObject:savedSearch]; + + if (countBefore != savedSearches.count) + { + *outDidModify = YES; + } + } + + return (savedSearches); + }]; + + [self didChangeValueForKey:@"savedSearches"]; +} + +- (void)addSavedSearchesObserver:(id)owner withInitial:(BOOL)initial updateHandler:(void(^)(id owner, NSArray * _Nullable savedSearches, BOOL initial))updateHandler +{ + __block BOOL isInitial = initial; + + [self.keyValueStore addObserver:^(OCKeyValueStore * _Nonnull store, id _Nullable owner, OCKeyValueStoreKey _Nonnull key, id _Nullable newValue) { + NSMutableArray *savedSearches = OCTypedCast(newValue, NSMutableArray); + BOOL isInitialCall = isInitial; + isInitial = NO; + + dispatch_async(dispatch_get_main_queue(), ^{ + updateHandler(owner, savedSearches, isInitialCall); + }); + } forKey:OCKeyValueStoreKeySavedSearches withOwner:owner initial:initial]; +} + +@end + +OCKeyValueStoreKey OCKeyValueStoreKeySavedSearches = @"savedSearches"; diff --git a/ownCloudAppFramework/ownCloudApp.h b/ownCloudAppFramework/ownCloudApp.h index c4cec81f9..543e790eb 100644 --- a/ownCloudAppFramework/ownCloudApp.h +++ b/ownCloudAppFramework/ownCloudApp.h @@ -30,6 +30,7 @@ FOUNDATION_EXPORT const unsigned char ownCloudAppVersionString[]; #import #import #import +#import #import #import #import @@ -82,3 +83,6 @@ FOUNDATION_EXPORT const unsigned char ownCloudAppVersionString[]; #import #import + +#import +#import diff --git a/ownCloudAppFrameworkTests/SearchSegmentationTests.m b/ownCloudAppFrameworkTests/SearchSegmentationTests.m index 56400f7da..9421261dd 100644 --- a/ownCloudAppFrameworkTests/SearchSegmentationTests.m +++ b/ownCloudAppFrameworkTests/SearchSegmentationTests.m @@ -60,6 +60,157 @@ - (void)testStringSegmentation }]; } +- (void)testStringSegmentationWithCursorPosition +{ + NSArray *> *testCases = @[ + @{ + @"term" : @"012 456", + @"cursorPosition" : @(2), + @"expectedSegments" : @[ + @"012", + @"456" + ], + @"expectedSegmentOffset" : @(0) + }, + + @{ + @"term" : @"012 456", + @"cursorPosition" : @(4), + @"expectedSegments" : @[ + @"012", + @"456" + ], + @"expectedSegmentOffset" : @(-1) + }, + + @{ + @"term" : @"012 456", + @"cursorPosition" : @(5), + @"expectedSegments" : @[ + @"012", + @"456" + ], + @"expectedSegmentOffset" : @(1) + }, + + @{ + @"term" : @"012 456", + @"cursorPosition" : @(6), + @"expectedSegments" : @[ + @"012", + @"456" + ], + @"expectedSegmentOffset" : @(1) + }, + + @{ + @"term" : @"012 456", + @"cursorPosition" : @(7), + @"expectedSegments" : @[ + @"012", + @"456" + ], + @"expectedSegmentOffset" : @(1) + }, + + @{ + @"term" : @"012 456 ", + @"cursorPosition" : @(8), + @"expectedSegments" : @[ + @"012", + @"456" + ], + @"expectedSegmentOffset" : @(-1) + }, + + @{ + @"term" : @"123 \"678 ", + @"cursorPosition" : @(9), + @"expectedSegments" : @[ + @"123", + @"\"678 \"" + ], + @"expectedSegmentOffset" : @(1) + }, + + @{ + @"term" : @"123 \"678 X\"", + @"cursorPosition" : @(10), + @"expectedSegments" : @[ + @"123", + @"\"678 X\"" + ], + @"expectedSegmentOffset" : @(1) + }, + + @{ + @"term" : @"123 \"678 X\" ", + @"cursorPosition" : @(11), + @"expectedSegments" : @[ + @"123", + @"\"678 X\"" + ], + @"expectedSegmentOffset" : @(1) + }, + + @{ + @"term" : @"123 \"678 X\" ", + @"cursorPosition" : @(12), + @"expectedSegments" : @[ + @"123", + @"\"678 X\"" + ], + @"expectedSegmentOffset" : @(-1) + }, + + @{ + @"term" : @"123 \"678 X ", + @"cursorPosition" : @(11), + @"expectedSegments" : @[ + @"123", + @"\"678 X \"" + ], + @"expectedSegmentOffset" : @(1) + }, + + @{ + @"term" : @"123 \"678 X ", + @"cursorPosition" : @(12), + @"expectedSegments" : @[ + @"123", + @"\"678 X \"" + ], + @"expectedSegmentOffset" : @(-1) + } + ]; + + for (NSDictionary *testCase in testCases) + { + NSString *term = testCase[@"term"]; + NSArray *expectedSegments = testCase[@"expectedSegments"]; + NSNumber *cursorPosition = testCase[@"cursorPosition"]; + NSNumber *expectedSegmentOffset = testCase[@"expectedSegmentOffset"]; + __block NSInteger segmentWithCursorOffset = -1; + + NSArray *searchSegments = [term segmentedForSearchWithQuotationMarks:YES cursorPosition:cursorPosition]; + NSMutableArray *segments = [NSMutableArray new]; + + [searchSegments enumerateObjectsUsingBlock:^(OCSearchSegment * _Nonnull searchSegment, NSUInteger idx, BOOL * _Nonnull stop) { + [segments addObject:searchSegment.segmentedString]; + if (searchSegment.hasCursor) + { + segmentWithCursorOffset = idx; + } + }]; + + XCTAssert([segments isEqual:expectedSegments], @"segments %@ doesn't match expectation %@", segments, expectedSegments); + if (expectedSegmentOffset != nil) + { + XCTAssert((segmentWithCursorOffset == expectedSegmentOffset.integerValue), @"segment cursor offset %ld doesn't match expectation %ld for cursor position %@", segmentWithCursorOffset, expectedSegmentOffset.integerValue, cursorPosition); + } + } +} + - (void)testDateComputations { NSLog(@"Start of day(-2): %@", [NSDate startOfRelativeDay:-2]); diff --git a/ownCloudAppShared/Client/Collection Views/Cells/ActionCell.swift b/ownCloudAppShared/Client/Collection Views/Cells/ActionCell.swift index 49045571d..f84f3866c 100644 --- a/ownCloudAppShared/Client/Collection Views/Cells/ActionCell.swift +++ b/ownCloudAppShared/Client/Collection Views/Cells/ActionCell.swift @@ -80,6 +80,7 @@ class ActionCell: ThemeableCollectionViewCell { // titleLabel.setContentHuggingPriority(.required, for: .horizontal) titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + titleLabel.setContentCompressionResistancePriority(.required, for: .vertical) titleLabel.lineBreakMode = .byWordWrapping titleLabel.numberOfLines = 1 diff --git a/ownCloudAppShared/Client/Collection Views/Cells/SavedSearchCell.swift b/ownCloudAppShared/Client/Collection Views/Cells/SavedSearchCell.swift new file mode 100644 index 000000000..d298d35f1 --- /dev/null +++ b/ownCloudAppShared/Client/Collection Views/Cells/SavedSearchCell.swift @@ -0,0 +1,205 @@ +// +// SavedSearchCell.swift +// ownCloudAppShared +// +// Created by Felix Schwarz on 22.09.22. +// Copyright © 2022 ownCloud GmbH. All rights reserved. +// + +/* + * Copyright (C) 2022, ownCloud GmbH. + * + * This code is covered by the GNU Public License Version 3. + * + * For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ + * You should have received a copy of this license along with this program. If not, see . + * + */ + +import UIKit +import ownCloudSDK +import ownCloudApp + +class SavedSearchCell: ThemeableCollectionViewCell { + override init(frame: CGRect) { + super.init(frame: frame) + configure() + configureLayout() + } + + required init?(coder: NSCoder) { + fatalError() + } + + let iconView = UIImageView() + let titleLabel = UILabel() + let segmentView = SegmentView(with: [], truncationMode: .truncateTail) + + var iconInsets: UIEdgeInsets = UIEdgeInsets(top: 5, left: 5, bottom: 0, right: 5) + var titleInsets: UIEdgeInsets = UIEdgeInsets(top: 5, left: 3, bottom: 5, right: 3) + var titleSegmentSpacing: CGFloat = 5 + + var title: String? { + didSet { + titleLabel.text = title + } + } + var icon: UIImage? { + didSet { + iconView.image = icon + } + } + var items: [SegmentViewItem]? { + didSet { + segmentView.items = items ?? [] + } + } + var type: OCActionType = .regular { + didSet { + if superview != nil { + applyThemeCollectionToCellContents(theme: Theme.shared, collection: Theme.shared.activeCollection, state: .normal) + } + } + } + + func configure() { + iconView.translatesAutoresizingMaskIntoConstraints = false + titleLabel.translatesAutoresizingMaskIntoConstraints = false + segmentView.translatesAutoresizingMaskIntoConstraints = false + + contentView.addSubview(titleLabel) + contentView.addSubview(iconView) + contentView.addSubview(segmentView) + + iconView.image = icon + iconView.contentMode = .scaleAspectFit + + titleLabel.setContentHuggingPriority(.required, for: .vertical) + titleLabel.setContentCompressionResistancePriority(.required, for: .vertical) + + titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + titleLabel.lineBreakMode = .byWordWrapping + titleLabel.numberOfLines = 1 + + iconView.setContentHuggingPriority(.required, for: .horizontal) + + let backgroundConfig = UIBackgroundConfiguration.clear() + backgroundConfiguration = backgroundConfig + } + + func configureLayout() { + iconInsets = UIEdgeInsets(top: 11, left: 10, bottom: 13, right: 5) + titleInsets = UIEdgeInsets(top: 13, left: 3, bottom: 13, right: 10) + + titleLabel.textAlignment = .left + + titleLabel.font = UIFont.preferredFont(forTextStyle: .body) + titleLabel.adjustsFontForContentSizeCategory = true + + self.configuredConstraints = [ + iconView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: iconInsets.left), + iconView.trailingAnchor.constraint(equalTo: titleLabel.leadingAnchor, constant: -(iconInsets.right + titleInsets.left)), + iconView.centerYAnchor.constraint(equalTo: titleLabel.centerYAnchor), + // iconView.topAnchor.constraint(equalTo: contentView.topAnchor, constant: iconInsets.top), + // iconView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -iconInsets.bottom), + // iconView.widthAnchor.constraint(equalTo: iconView.heightAnchor), + iconView.widthAnchor.constraint(equalToConstant: 24), + iconView.heightAnchor.constraint(equalToConstant: 24), + + titleLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -titleInsets.right), + titleLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: titleInsets.top), + titleLabel.bottomAnchor.constraint(equalTo: segmentView.topAnchor, constant: -titleSegmentSpacing), + + segmentView.leadingAnchor.constraint(equalTo: titleLabel.leadingAnchor), + segmentView.trailingAnchor.constraint(equalTo: titleLabel.trailingAnchor), + segmentView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -titleInsets.bottom) + ] + } + + override func updateConfiguration(using state: UICellConfigurationState) { + let collection = Theme.shared.activeCollection + var backgroundConfig = backgroundConfiguration?.updated(for: state) + + if state.isHighlighted || state.isSelected || (state.cellDropState == .targeted) { + backgroundConfig?.backgroundColor = (type == .destructive) ? collection.destructiveColors.highlighted.background : collection.tableRowButtonColors.filledColorPairCollection.highlighted.background + } else { + backgroundConfig?.backgroundColor = (type == .destructive) ? collection.destructiveColors.normal.background : collection.tableRowButtonColors.filledColorPairCollection.normal.background + } + + backgroundConfig?.cornerRadius = 8 + + backgroundConfiguration = backgroundConfig + } + + override func applyThemeCollectionToCellContents(theme: Theme, collection: ThemeCollection, state: ThemeItemState) { + super.applyThemeCollectionToCellContents(theme: theme, collection: collection, state: state) + + titleLabel.textColor = (type == .destructive) ? collection.destructiveColors.normal.foreground : collection.tintColor + iconView.tintColor = (type == .destructive) ? collection.destructiveColors.normal.foreground : collection.tintColor + + setNeedsUpdateConfiguration() + } +} + +extension OCSavedSearch { + private var queryConditionsForDisplay: [OCQueryCondition] { + let searchSegments = (searchTerm as NSString).segmentedForSearch(withQuotationMarks: true) + var queryConditions: [OCQueryCondition] = [] + + for searchSegment in searchSegments { + if let queryCondition = OCQueryCondition.forSearchSegment(searchSegment) { + queryConditions.append(queryCondition) + } + } + + return queryConditions + } + + public var segmentViewItemsForDisplay: [SegmentViewItem] { + let conditions = queryConditionsForDisplay + var items: [SegmentViewItem] = [] + + for condition in conditions { + var item: SegmentViewItem? + + if condition.property == .name || (condition.localizedDescription == nil) { + item = SegmentViewItem(with: nil, title: condition.searchSegment, style: .plain, titleTextStyle: .footnote) + item?.insets = .zero + } else { + var icon: UIImage? + if let symbolName = condition.symbolName { + icon = UIImage(systemName: symbolName)?.withRenderingMode(.alwaysTemplate) + } + item = SegmentViewItem(with: icon, title: condition.localizedDescription, style: .token, titleTextStyle: .caption1) + item?.cornerStyle = .round(points: 3) + } + + if let item = item { + items.append(item) + } + } + + return items + } +} + +extension SavedSearchCell { + static let savedTemplateIcon = UIImage(systemName: "square.dashed.inset.filled")?.withRenderingMode(.alwaysTemplate) + static let savedSearchIcon = UIImage(systemName: "gearshape.fill")?.withRenderingMode(.alwaysTemplate) + + static func registerCellProvider() { + let savedSearchCellRegistration = UICollectionView.CellRegistration { (cell, indexPath, collectionItemRef) in + collectionItemRef.ocCellConfiguration?.configureCell(for: collectionItemRef, with: { itemRecord, item, cellConfiguration in + if let savedSearch = OCDataRenderer.default.renderItem(item, asType: .savedSearch, error: nil, withOptions: nil) as? OCSavedSearch { + cell.title = (savedSearch.isNameUserDefined && savedSearch.name.count > 0) ? savedSearch.name : (savedSearch.isTemplate ? "Search template".localized : "Search view".localized) + cell.icon = savedSearch.isTemplate ? savedTemplateIcon : savedSearchIcon + cell.items = savedSearch.segmentViewItemsForDisplay + } + }) + } + + CollectionViewCellProvider.register(CollectionViewCellProvider(for: .savedSearch, with: { collectionView, cellConfiguration, itemRecord, itemRef, indexPath in + return collectionView.dequeueConfiguredReusableCell(using: savedSearchCellRegistration, for: indexPath, item: itemRef) + })) + } +} diff --git a/ownCloudAppShared/Client/Collection Views/Cells/ViewCell.swift b/ownCloudAppShared/Client/Collection Views/Cells/ViewCell.swift index 9631f6b34..5a833bf5b 100644 --- a/ownCloudAppShared/Client/Collection Views/Cells/ViewCell.swift +++ b/ownCloudAppShared/Client/Collection Views/Cells/ViewCell.swift @@ -19,25 +19,45 @@ import UIKit class ViewCell: ThemeableCollectionViewListCell { + var hostedView: UIView? { + willSet { + if hostedView != newValue { + hostedView?.removeFromSuperview() + } + } + + didSet { + if let hostedView = hostedView, hostedView != oldValue { + hostedView.layoutIfNeeded() + + contentView.addSubview(hostedView) + + NSLayoutConstraint.activate([ + // Fill cell.contentView + // -> these constraints are applied with .defaultHigh priority (not the default of .required) to not trigger + // an unsatisfiable constraints warning in case a cell is re-used and the new view's size conflicts with the + // system's "UIView-Encapsulated-Layout-Height" constraint + hostedView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor).with(priority: .defaultHigh), + hostedView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor).with(priority: .defaultHigh), + hostedView.topAnchor.constraint(equalTo: contentView.topAnchor).with(priority: .defaultHigh), + hostedView.bottomAnchor.constraint(equalTo: contentView.bottomAnchor).with(priority: .defaultHigh), + + // Extend cell seperator to contentView.leadingAnchor + separatorLayoutGuide.leadingAnchor.constraint(equalTo: contentView.leadingAnchor) + ]) + } + } + } + + override func prepareForReuse() { + super.prepareForReuse() + hostedView = nil + } + static func registerCellProvider() { let itemListCellRegistration = UICollectionView.CellRegistration { (cell, indexPath, collectionItemRef) in collectionItemRef.ocCellConfiguration?.configureCell(for: collectionItemRef, with: { itemRecord, item, cellConfiguration in - if let view = item as? UIView { - let contentView = cell.contentView - - contentView.addSubview(view) - - NSLayoutConstraint.activate([ - // Fill cell.contentView - view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor), - view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor), - view.topAnchor.constraint(equalTo: contentView.topAnchor), - view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor), - - // Extend cell seperator to contentView.leadingAnchor - cell.separatorLayoutGuide.leadingAnchor.constraint(equalTo: cell.contentView.leadingAnchor) - ]) - } + cell.hostedView = item as? UIView }) } diff --git a/ownCloudAppShared/Client/Collection Views/CollectionViewCellProvider+StandardImplementations.swift b/ownCloudAppShared/Client/Collection Views/CollectionViewCellProvider+StandardImplementations.swift index 561d4ec15..c44dc0466 100644 --- a/ownCloudAppShared/Client/Collection Views/CollectionViewCellProvider+StandardImplementations.swift +++ b/ownCloudAppShared/Client/Collection Views/CollectionViewCellProvider+StandardImplementations.swift @@ -26,6 +26,7 @@ public extension CollectionViewCellProvider { ItemListCell.registerCellProvider() ExpandableResourceCell.registerCellProvider() ActionCell.registerCellProvider() + SavedSearchCell.registerCellProvider() ViewCell.registerCellProvider() registerPresentableCellProvider() diff --git a/ownCloudAppShared/Client/Collection Views/CollectionViewController.swift b/ownCloudAppShared/Client/Collection Views/CollectionViewController.swift index 31eae3ef6..34dde04da 100644 --- a/ownCloudAppShared/Client/Collection Views/CollectionViewController.swift +++ b/ownCloudAppShared/Client/Collection Views/CollectionViewController.swift @@ -29,6 +29,8 @@ open class CollectionViewController: UIViewController, UICollectionViewDelegate, var highlightItemReference: OCDataItemReference? var didHighlightItemReference: Bool = false + var emptyCellRegistration: UICollectionView.CellRegistration? + public init(context inContext: ClientContext?, sections inSections: [CollectionViewSection]?, useStackViewRoot: Bool = false, hierarchic: Bool = false, highlightItemReference: OCDataItemReference? = nil) { supportsHierarchicContent = hierarchic usesStackViewRoot = useStackViewRoot @@ -36,6 +38,9 @@ open class CollectionViewController: UIViewController, UICollectionViewDelegate, super.init(nibName: nil, bundle: nil) + emptyCellRegistration = UICollectionView.CellRegistration(handler: { cell, indexPath, itemIdentifier in + }) + inContext?.postInitialize(owner: self) clientContext = ClientContext(with: inContext, modifier: { context in @@ -171,8 +176,12 @@ open class CollectionViewController: UIViewController, UICollectionViewDelegate, return UICollectionViewCompositionalLayout(sectionProvider: { [weak self] sectionIndex, layoutEnvironment in if let self = self { - if sectionIndex >= 0, sectionIndex < self.sections.count { - return self.sections[sectionIndex].provideCollectionLayoutSection(layoutEnvironment: layoutEnvironment) + let visibleSections = self.sections.filter({ section in + return !section.hidden + }) + + if sectionIndex >= 0, sectionIndex < visibleSections.count, visibleSections.count > 0 { + return visibleSections[sectionIndex].provideCollectionLayoutSection(layoutEnvironment: layoutEnvironment) } } @@ -201,7 +210,7 @@ open class CollectionViewController: UIViewController, UICollectionViewDelegate, return section.provideReusableCell(for: collectionView, collectionItemRef: collectionItemRef, indexPath: indexPath) } - return UICollectionViewCell() + return self?.provideEmptyFallbackCell(for: indexPath, item: collectionItemRef) } // initial data @@ -710,6 +719,8 @@ open class CollectionViewController: UIViewController, UICollectionViewDelegate, if event != .initial { collectionView.setCollectionViewLayout(createCollectionViewLayout(), animated: false) } + + collectionView.backgroundColor = collection.tableBackgroundColor } } @@ -717,4 +728,25 @@ public extension CollectionViewController { func relayout(cell: UICollectionViewCell) { collectionViewDataSource.apply(collectionViewDataSource.snapshot(), animatingDifferences: true) } + + func provideEmptyFallbackCell(for indexPath: IndexPath, item itemRef: CollectionViewController.ItemRef) -> UICollectionViewCell { + if let emptyCellRegistration = emptyCellRegistration { + let reUseIdentifier : CollectionViewController.ItemRef = NSString(string: "_empty_\(String(describing: itemRef))") + return collectionView.dequeueConfiguredReusableCell(using: emptyCellRegistration, for: indexPath, item: reUseIdentifier) + } + + return UICollectionViewCell.emptyFallbackCell + } +} + +public extension UICollectionViewCell { + static var emptyFallbackCell: UICollectionViewCell { + return CollectionViewFallbackCell() // If the code reaches this point, an exception will be returned by UICollectionView* + } +} + +public class CollectionViewFallbackCell : UICollectionViewCell { + public override var reuseIdentifier: String? { + return "_emptyFallbackCell" + } } diff --git a/ownCloudAppShared/Client/Collection Views/CollectionViewSection.swift b/ownCloudAppShared/Client/Collection Views/CollectionViewSection.swift index 140d4c5a1..95048ca30 100644 --- a/ownCloudAppShared/Client/Collection Views/CollectionViewSection.swift +++ b/ownCloudAppShared/Client/Collection Views/CollectionViewSection.swift @@ -190,8 +190,10 @@ public class CollectionViewSection: NSObject { set { _cellStyle = newValue - OnMainThread { - self.collectionViewController?.reload(sections: [self], animated: false) + if _cellStyle != newValue { + OnMainThread { + self.collectionViewController?.reload(sections: [self], animated: false) + } } } } @@ -282,9 +284,13 @@ public class CollectionViewSection: NSObject { cell = cellProvider.provideCell(for: collectionView, cellConfiguration: cellConfiguration, itemRecord: itemRecord, collectionItemRef: collectionItemRef, indexPath: indexPath) } } + + if cell == nil { + cell = collectionViewController.provideEmptyFallbackCell(for: indexPath, item: collectionItemRef) + } } - return cell ?? UICollectionViewCell() + return cell ?? UICollectionViewCell.emptyFallbackCell } // MARK: - Section layout diff --git a/ownCloudAppShared/Client/Collection Views/View Controllers/ClientItemViewController.swift b/ownCloudAppShared/Client/Collection Views/View Controllers/ClientItemViewController.swift index f23c8bd88..e62dd4f3b 100644 --- a/ownCloudAppShared/Client/Collection Views/View Controllers/ClientItemViewController.swift +++ b/ownCloudAppShared/Client/Collection Views/View Controllers/ClientItemViewController.swift @@ -21,12 +21,14 @@ import ownCloudSDK import ownCloudApp import Intents -open class ClientItemViewController: CollectionViewController, SortBarDelegate, DropTargetsProvider, SearchViewControllerDelegate, RevealItemAction { +open class ClientItemViewController: CollectionViewController, SortBarDelegate, DropTargetsProvider, SearchViewControllerDelegate, RevealItemAction, SearchViewControllerHost { public enum ContentState : String, CaseIterable { case loading case empty case hasContent + + case searchNonItemContent } public var query: OCQuery? @@ -46,11 +48,11 @@ open class ClientItemViewController: CollectionViewController, SortBarDelegate, public var emptyItemListDataSource : OCDataSourceArray = OCDataSourceArray() public var emptyItemListDecisionSubscription : OCDataSourceSubscription? - public var emptyItemListItem : OCDataItemPresentable? + public var emptyItemListItem : ComposedMessageView? + public var emptySectionDataSource: OCDataSourceComposition? public var emptySection: CollectionViewSection? - public var loadingListItem : OCDataItemPresentable? - public var emptySearchResultsItem: OCDataItemPresentable? + public var loadingListItem : ComposedMessageView? private var stateObservation : NSKeyValueObservation? private var queryRootItemObservation : NSKeyValueObservation? @@ -152,7 +154,9 @@ open class ClientItemViewController: CollectionViewController, SortBarDelegate, } } - emptySection = CollectionViewSection(identifier: "empty", dataSource: emptyItemListDataSource, cellStyle: .init(with: .fillSpace), cellLayout: .fullWidth(itemHeightDimension: .estimated(54), groupHeightDimension: .estimated(54), edgeSpacing: NSCollectionLayoutEdgeSpacing(leading: .fixed(0), top: .fixed(10), trailing: .fixed(0), bottom: .fixed(10)), contentInsets: NSDirectionalEdgeInsets(top: 10, leading: 20, bottom: 10, trailing: 20)), clientContext: itemControllerContext) + emptySectionDataSource = OCDataSourceComposition(sources: [ emptyItemListDataSource ]) + + emptySection = CollectionViewSection(identifier: "empty", dataSource: emptySectionDataSource, cellStyle: .init(with: .fillSpace), cellLayout: .fullWidth(itemHeightDimension: .estimated(54), groupHeightDimension: .estimated(54), edgeSpacing: NSCollectionLayoutEdgeSpacing(leading: .fixed(0), top: .fixed(10), trailing: .fixed(0), bottom: .fixed(10)), contentInsets: NSDirectionalEdgeInsets(top: 10, leading: 20, bottom: 10, trailing: 20)), clientContext: itemControllerContext) sections.append(emptySection!) super.init(context: itemControllerContext, sections: sections, useStackViewRoot: true, highlightItemReference: highlightItemReference) @@ -175,13 +179,25 @@ open class ClientItemViewController: CollectionViewController, SortBarDelegate, }, on: .main, trackDifferences: true, performIntialUpdate: true) if let queryDatasource = query?.queryResultsDataSource { - emptyItemListItem = OCDataItemPresentable(reference: "_emptyItemList" as NSString, originalDataItemType: nil, version: nil) - emptyItemListItem?.title = "This folder is empty. Fill it with content:".localized - emptyItemListItem?.childrenDataSourceProvider = nil + emptyItemListItem = ComposedMessageView(elements: [ + .image(UIImage(systemName: "folder.fill")!, size: CGSize(width: 64, height: 48), alignment: .centered), + .text("No contents".localized, style: .system(textStyle: .title3, weight: .semibold), alignment: .centered), + .spacing(25), + .text("This folder is empty. Fill it with content:".localized, style: .systemSecondary(textStyle: .body), alignment: .centered) + ]) - loadingListItem = OCDataItemPresentable(reference: "_loadingListItem" as NSString, originalDataItemType: nil, version: nil) - loadingListItem?.title = "Loading…".localized - loadingListItem?.childrenDataSourceProvider = nil + emptyItemListItem?.elementInsets = NSDirectionalEdgeInsets(top: 20, leading: 0, bottom: 2, trailing: 0) + emptyItemListItem?.backgroundView = nil + + let indeterminateProgress: Progress = .indeterminate() + indeterminateProgress.isCancellable = false + + loadingListItem = ComposedMessageView(elements: [ + .spacing(25), + .progressCircle(with: indeterminateProgress), + .spacing(25), + .text("Loading…".localized, style: .system(textStyle: .title3, weight: .semibold), alignment: .centered) + ]) emptyItemListDecisionSubscription = queryDatasource.subscribe(updateHandler: { [weak self] (subscription) in self?.updateEmptyItemList(from: subscription) @@ -253,7 +269,6 @@ open class ClientItemViewController: CollectionViewController, SortBarDelegate, // Setup sort bar sortBar = SortBar(sortMethod: sortMethod) sortBar?.translatesAutoresizingMaskIntoConstraints = false - sortBar?.heightAnchor.constraint(equalToConstant: 40).isActive = true sortBar?.delegate = self sortBar?.sortMethod = sortMethod sortBar?.searchScope = searchScope @@ -327,14 +342,38 @@ open class ClientItemViewController: CollectionViewController, SortBarDelegate, func recomputeContentState() { OnMainThread { - switch self.itemsQueryDataSource?.state { - case .loading: - self.contentState = .loading - - case .idle: - self.contentState = (self.emptyItemListDecisionSubscription?.snapshotResettingChangeTracking(true).numberOfItems == 0) ? .empty : .hasContent + if self.searchActive == true { + // Search is active, adapt state to either results (.hasContent) or noResults/suggestions (.searchNonItemContent) + if let searchResultsContent = self.searchResultsContent { + if searchResultsContent.type != .results { + self.contentState = .searchNonItemContent + } else { + self.contentState = .hasContent + } + } else { + self.contentState = .searchNonItemContent + } + } else { + // Regular usage, use itemsQueryDataSource to determine state + switch self.itemsQueryDataSource?.state { + case .loading: + self.contentState = .loading + + case .idle: + let numberOfItems = self.emptyItemListDecisionSubscription?.snapshotResettingChangeTracking(true).numberOfItems + + if let numberOfItems = numberOfItems, numberOfItems > 0 { + self.contentState = .hasContent + } else { + if (numberOfItems == nil) || (self.query?.rootItem == nil) { + self.contentState = .loading + } else { + self.contentState = .empty + } + } - default: break + default: break + } } } } @@ -343,6 +382,10 @@ open class ClientItemViewController: CollectionViewController, SortBarDelegate, public var contentState : ContentState = .loading { didSet { let hasRootItem = (query?.rootItem != nil) + let itemSectionHidden = itemSection?.hidden + var itemSectionHiddenNew = false + let emptySectionHidden = emptySection?.hidden + var emptySectionHiddenNew = false if (contentState == oldValue) && (hadRootItem == hasRootItem) { return @@ -379,6 +422,20 @@ open class ClientItemViewController: CollectionViewController, SortBarDelegate, if let sortBar = sortBar { itemsLeadInDataSource.setVersionedItems([ sortBar ]) } + + emptySectionHiddenNew = true + + case .searchNonItemContent: + emptyItemListDataSource.setItems(nil, updated: nil) + itemsLeadInDataSource.setVersionedItems([ ]) + itemSectionHiddenNew = true + } + + if (itemSectionHidden != itemSectionHiddenNew) || (emptySectionHidden != emptySectionHiddenNew) { + updateSections(with: { sections in + self.itemSection?.hidden = itemSectionHiddenNew + self.emptySection?.hidden = emptySectionHiddenNew + }, animated: false) } } } @@ -647,21 +704,87 @@ open class ClientItemViewController: CollectionViewController, SortBarDelegate, // MARK: - Search open var searchController: UISearchController? - var searchViewController: SearchViewController? + open var searchViewController: SearchViewController? @objc open func startSearch() { if searchViewController == nil { if let clientContext = clientContext, let cellStyle = itemSection?.cellStyle { - searchViewController = SearchViewController(with: clientContext, scopes: [ - // In this folder + var scopes : [SearchScope] = [ + // - In this folder .modifyingQuery(with: clientContext, localizedName: "Folder".localized), - // + Folder and subfolders - // + This space + // - Folder and subfolders (tree / container) + .containerSearch(with: clientContext, cellStyle: cellStyle, localizedName: "Tree".localized) + ] + + // - Drive + if clientContext.core?.useDrives == true { + let driveName = "Space".localized + scopes.append(.driveSearch(with: clientContext, cellStyle: cellStyle, localizedName: driveName)) + } + + // - Account + scopes.append(.accountSearch(with: clientContext, cellStyle: cellStyle, localizedName: "Account".localized)) + + // No results + let noResultContent = SearchViewController.Content(type: .noResults, source: OCDataSourceArray(), style: emptySection!.cellStyle) + let noResultsView = ComposedMessageView.infoBox(image: UIImage(systemName: "magnifyingglass"), title: "No matches".localized, subtitle: "The search term you entered did not match any item in the selected scope.".localized) + + (noResultContent.source as? OCDataSourceArray)?.setVersionedItems([ + noResultsView + ]) + + // Suggestion view + let suggestionsSource = OCDataSourceArray() + let suggestionsContent = SearchViewController.Content(type: .suggestion, source: suggestionsSource, style: emptySection!.cellStyle) - // Account - .globalSearch(with: clientContext, cellStyle: cellStyle, localizedName: "Account".localized) - ], delegate: self) + if let vault = clientContext.core?.vault { + vault.addSavedSearchesObserver(suggestionsSource, withInitial: true) { suggestionsSource, savedSearches, isInitial in + guard let suggestionsSource = suggestionsSource as? OCDataSourceArray else { + return + } + + var suggestionItems : [OCDataItem & OCDataItemVersioning] = [] + + // Offer saved search templates + if let savedTemplates = vault.savedSearches?.filter({ savedSearch in + return savedSearch.isTemplate + }), savedTemplates.count > 0 { + let savedSearchTemplatesHeaderView = ComposedMessageView(elements: [ + .spacing(10), + .text("Saved search templates".localized, style: .system(textStyle: .headline), alignment: .leading, insets: .zero) + ]) + savedSearchTemplatesHeaderView.elementInsets = .zero + + suggestionItems.append(savedSearchTemplatesHeaderView) + suggestionItems.append(contentsOf: savedTemplates) + } + + // Offer saved searches + if let savedSearches = vault.savedSearches?.filter({ savedSearch in + return !savedSearch.isTemplate + }), savedSearches.count > 0 { + let savedSearchTemplatesHeaderView = ComposedMessageView(elements: [ + .spacing(10), + .text("Saved search views".localized, style: .system(textStyle: .headline), alignment: .leading, insets: .zero) + ]) + savedSearchTemplatesHeaderView.elementInsets = .zero + + suggestionItems.append(savedSearchTemplatesHeaderView) + suggestionItems.append(contentsOf: savedSearches) + } + + // Provide "Enter a search term" placeholder if there is no other content available + if suggestionItems.count == 0 { + suggestionItems.append( ComposedMessageView.infoBox(image: nil, subtitle: "Enter a search term".localized) ) + } + + suggestionsSource.setVersionedItems(suggestionItems) + } + } + + // Create and install SearchViewController + searchViewController = SearchViewController(with: clientContext, scopes: scopes, suggestionContent: suggestionsContent, noResultContent: noResultContent, delegate: self) if let searchViewController = searchViewController { self.addStacked(child: searchViewController, position: .top) @@ -674,16 +797,52 @@ open class ClientItemViewController: CollectionViewController, SortBarDelegate, if let searchViewController = searchViewController { self.removeStacked(child: searchViewController) } - searchResultsDataSource = nil + searchResultsContent = nil searchViewController = nil + + itemSectionDataSource?.setInclude(true, for: itemsLeadInDataSource) } // MARK: - SearchViewControllerDelegate + var searchResultsContent: SearchViewController.Content? { + didSet { + if let content = searchResultsContent { + let contentSource = content.source + let contentStyle = content.style + + switch content.type { + case .results: + if searchResultsDataSource != contentSource { + searchResultsDataSource = contentSource + } + + if let style = contentStyle ?? preSearchCellStyle, style != itemSection?.cellStyle { + itemSection?.cellStyle = style + } + + searchNonItemDataSource = nil + + case .noResults, .suggestion: + searchResultsDataSource = nil + searchNonItemDataSource = contentSource + } + } else { + searchResultsDataSource = nil + searchNonItemDataSource = nil + } + + recomputeContentState() + } + } + var searchResultsDataSource: OCDataSource? { willSet { if let oldDataSource = searchResultsDataSource, let itemsQueryDataSource = itemsQueryDataSource, oldDataSource != itemsQueryDataSource { itemSectionDataSource?.removeSources([ oldDataSource ]) - itemSectionDataSource?.setInclude(true, for: itemsQueryDataSource) + + if (newValue == nil) || (newValue == itemsQueryDataSource) { + itemSectionDataSource?.setInclude(true, for: itemsQueryDataSource) + } } } @@ -695,27 +854,39 @@ open class ClientItemViewController: CollectionViewController, SortBarDelegate, } } + var searchNonItemDataSource: OCDataSource? { + willSet { + if let oldDataSource = searchNonItemDataSource, oldDataSource != newValue { + emptySectionDataSource?.removeSources([ oldDataSource ]) + } + } + + didSet { + if let newDataSource = searchNonItemDataSource, newDataSource != oldValue { + emptySectionDataSource?.addSources([ newDataSource ]) + } + } + } + private var preSearchCellStyle : CollectionViewCellStyle? + var searchActive : Bool? public func searchBegan(for viewController: SearchViewController) { preSearchCellStyle = itemSection?.cellStyle + searchActive = true updateSections(with: { sections in self.driveSection?.hidden = true }, animated: true) } - public func search(for viewController: SearchViewController, withResults resultsDataSource: OCDataSource?, style: CollectionViewCellStyle?) { - if searchResultsDataSource != resultsDataSource { - searchResultsDataSource = resultsDataSource - } - - if let style = style ?? preSearchCellStyle, style != itemSection?.cellStyle { - itemSection?.cellStyle = style - } + public func search(for viewController: SearchViewController, content: SearchViewController.Content?) { + searchResultsContent = content } public func searchEnded(for viewController: SearchViewController) { + searchActive = false + updateSections(with: { sections in self.driveSection?.hidden = false }, animated: true) @@ -725,5 +896,7 @@ open class ClientItemViewController: CollectionViewController, SortBarDelegate, } endSearch() + + recomputeContentState() } } diff --git a/ownCloudAppShared/Client/Data Item Interactions/OCSavedSearch+Interactions.swift b/ownCloudAppShared/Client/Data Item Interactions/OCSavedSearch+Interactions.swift new file mode 100644 index 000000000..d474447e6 --- /dev/null +++ b/ownCloudAppShared/Client/Data Item Interactions/OCSavedSearch+Interactions.swift @@ -0,0 +1,137 @@ +// +// OCSavedSearch+Interactions.swift +// ownCloudAppShared +// +// Created by Felix Schwarz on 22.09.22. +// Copyright © 2022 ownCloud GmbH. All rights reserved. +// + +/* + * Copyright (C) 2022, ownCloud GmbH. + * + * This code is covered by the GNU Public License Version 3. + * + * For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ + * You should have received a copy of this license along with this program. If not, see . + * + */ + +import UIKit +import ownCloudSDK +import ownCloudApp + +extension OCSavedSearch { + func canDelete(in context: ClientContext?) -> Bool { + guard let context = context, let core = context.core, let savedSearches = core.vault.savedSearches else { + return false + } + + return savedSearches.contains(where: { (savedSearch) in + return savedSearch.uuid == uuid + }) + } + + func delete(in context: ClientContext?) { + guard let context = context, let core = context.core else { + return + } + + core.vault.delete(self) + } + + func condition() -> OCQueryCondition? { + let searchTermCondition = OCQueryCondition.fromSearchTerm(searchTerm) + var composedCondition = searchTermCondition + + if let location = location { + var requirements: [OCQueryCondition] = [] + + if let driveID = location.driveID { + requirements.append(OCQueryCondition.where(.driveID, isEqualTo: driveID)) + } + + switch scope { + case .folder, .container: + if let path = location.path { + requirements.append(OCQueryCondition.where(.path, startsWith: path)) + } + + default: break + } + + if requirements.count > 0 { + if let searchTermCondition = searchTermCondition { + requirements.append(searchTermCondition) + } + composedCondition = .require(requirements) + } + } + + return composedCondition + } +} + +extension OCSavedSearch: DataItemSelectionInteraction { + public func handleSelection(in viewController: UIViewController?, with context: ClientContext?, completion: ((Bool) -> Void)?) -> Bool { + if isTemplate { + if let host = viewController as? SearchViewControllerHost { + host.searchViewController?.restore(savedTemplate: self) + completion?(true) + return true + } + } else { + if let condition = condition(), let context = context { + let query = OCQuery(condition: condition, inputFilter: nil) + DisplaySettings.shared.updateQuery(withDisplaySettings: query) + + let resultsContext = ClientContext(with: context, modifier: { context in + context.query = query + }) + + let queryViewController = ClientItemViewController(context: resultsContext, query: query) + queryViewController.navigationItem.title = name + + context.navigationController?.pushViewController(queryViewController, animated: true) + + completion?(true) + return true + } + } + + completion?(false) + return false + } +} + +extension OCSavedSearch: DataItemSwipeInteraction { + public func provideTrailingSwipeActions(with context: ClientContext?) -> UISwipeActionsConfiguration? { + guard canDelete(in: context) else { + return nil + } + + let deleteAction = UIContextualAction(style: .destructive, title: "Delete".localized, handler: { [weak self] (_ action, _ view, _ uiCompletionHandler) in + uiCompletionHandler(false) + self?.delete(in: context) + }) + deleteAction.image = UIImage(systemName: "trash")?.withRenderingMode(.alwaysTemplate) + + return UISwipeActionsConfiguration(actions: [ deleteAction ]) + } +} + +extension OCSavedSearch: DataItemContextMenuInteraction { + public func composeContextMenuItems(in viewController: UIViewController?, location: OCExtensionLocationIdentifier, with context: ClientContext?) -> [UIMenuElement]? { + guard canDelete(in: context) else { + return nil + } + + let deleteAction = UIAction(handler: { [weak self] action in + self?.delete(in: context) + }) + deleteAction.title = "Delete".localized + deleteAction.image = UIImage(systemName: "trash")?.withRenderingMode(.alwaysTemplate) + deleteAction.attributes = .destructive + + return [ deleteAction ] + } +} diff --git a/ownCloudAppShared/Client/Search/Item Search/ItemSearchSuggestionsViewController.swift b/ownCloudAppShared/Client/Search/Item Search/ItemSearchSuggestionsViewController.swift new file mode 100644 index 000000000..ade77a2f4 --- /dev/null +++ b/ownCloudAppShared/Client/Search/Item Search/ItemSearchSuggestionsViewController.swift @@ -0,0 +1,338 @@ +// +// ItemSearchSuggestionsViewController.swift +// ownCloudAppShared +// +// Created by Felix Schwarz on 08.09.22. +// Copyright © 2022 ownCloud GmbH. All rights reserved. +// + +/* + * Copyright (C) 2022, ownCloud GmbH. + * + * This code is covered by the GNU Public License Version 3. + * + * For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ + * You should have received a copy of this license along with this program. If not, see . + * + */ + +import UIKit +import ownCloudSDK +import ownCloudApp + +extension OCQueryCondition { + func matchesWith(anyOf searchElements: [SearchElement]) -> Bool { + return searchElements.contains(where: { element in element.isEquivalent(to: self) }) + } +} + +class ItemSearchSuggestionsViewController: UIViewController, SearchElementUpdating { + class Category { + typealias SelectionBehaviour = (_ deselectOption: OCQueryCondition, _ whenOption: OCQueryCondition, _ isSelected: Bool) -> Bool + + static let mutuallyExclusiveSelectionBehaviour : SelectionBehaviour = { (deselectOption, whenOption, isSelected) in + if isSelected, !deselectOption.isEquivalent(to: whenOption) { + return true + } + + return false + } + + var name: String + var selectionBehaviour: SelectionBehaviour + var options: [OCQueryCondition] + + var popupController: PopupButtonController? + + init(name: String, selectionBehaviour: @escaping SelectionBehaviour, options: [OCQueryCondition]) { + self.name = name + self.selectionBehaviour = selectionBehaviour + self.options = options + } + + func shouldDeselect(option optionCondition: OCQueryCondition, when otherOptionCondition: OCQueryCondition, isSelected: Bool) -> Bool { + return selectionBehaviour(optionCondition, otherOptionCondition, isSelected) + } + } + + var categories: [Category] = [ + Category(name: "Type".localized, selectionBehaviour: Category.mutuallyExclusiveSelectionBehaviour, options: [ + OCQueryCondition.fromSearchTerm(":file")!, + OCQueryCondition.fromSearchTerm(":folder")!, + OCQueryCondition.fromSearchTerm(":document")!, + OCQueryCondition.fromSearchTerm(":spreadsheet")!, + OCQueryCondition.fromSearchTerm(":presentation")!, + OCQueryCondition.fromSearchTerm(":pdf")!, + OCQueryCondition.fromSearchTerm(":image")!, + OCQueryCondition.fromSearchTerm(":video")!, + OCQueryCondition.fromSearchTerm(":audio")! + ]), + Category(name: "Date".localized, selectionBehaviour: Category.mutuallyExclusiveSelectionBehaviour, options: [ + OCQueryCondition.fromSearchTerm(":today")!, + OCQueryCondition.fromSearchTerm(":week")!, + OCQueryCondition.fromSearchTerm(":month")!, + OCQueryCondition.fromSearchTerm(":year")! + ]), + Category(name: "Size".localized, selectionBehaviour: Category.mutuallyExclusiveSelectionBehaviour, options: [ + OCQueryCondition.fromSearchTerm("smaller:10mb")!, + OCQueryCondition.fromSearchTerm("greater:10mb")!, + OCQueryCondition.fromSearchTerm("smaller:100mb")!, + OCQueryCondition.fromSearchTerm("greater:100mb")!, + OCQueryCondition.fromSearchTerm("smaller:500mb")!, + OCQueryCondition.fromSearchTerm("greater:500mb")!, + OCQueryCondition.fromSearchTerm("smaller:1gb")!, + OCQueryCondition.fromSearchTerm("greater:1gb")! + ]) + ] + + var stackView : UIStackView? + private var rootView : UIView? + + weak var scope: SearchScope? + + var categoryActiveButtonConfig : UIButton.Configuration? + var categoryUnusedButtonConfig : UIButton.Configuration? + + init(with scope: SearchScope) { + super.init(nibName: nil, bundle: nil) + self.scope = scope + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func requestName(title: String, message: String? = nil, placeholder: String? = nil, cancelButtonText: String? = "Cancel".localized, saveButtonText: String? = "Save".localized, completionHandler: @escaping (_ save: Bool, _ name: String?) -> Void) { + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: saveButtonText, style: .default, handler: { [weak alert] _ in + var text = alert?.textFields?.first?.text + if text?.count == 0 { text = nil } + completionHandler(true, text) + })) + alert.addAction(UIAlertAction(title: cancelButtonText, style: .cancel, handler: { _ in + completionHandler(false, nil) + })) + alert.addTextField(configurationHandler: { textField in + textField.placeholder = placeholder + }) + + self.present(alert, animated: true) + } + + override func loadView() { + // Stack view + stackView = UIStackView(frame: .zero) + stackView?.translatesAutoresizingMaskIntoConstraints = false + stackView?.axis = .horizontal + stackView?.distribution = .equalSpacing + stackView?.spacing = 0 + + // Saved search popup + savedSearchPopup = PopupButtonController(with: [], selectFirstChoice: false, dropDown: true, choiceHandler: { [weak self] choice, wasSelected in + if let scope = self?.scope, let command = choice.representedObject as? String { + switch command { + case "save-search": + if let savedSearch = scope.savedSearch as? OCSavedSearch, let vault = scope.clientContext.core?.vault { + OnMainThread { + self?.requestName(title: "Name of view".localized, placeholder: "Search view".localized, completionHandler: { save, name in + if save { + if let name = name { + savedSearch.name = name + } + vault.add(savedSearch) + } + }) + } + } + case "save-template": + if let savedSearch = scope.savedTemplate as? OCSavedSearch, let vault = scope.clientContext.core?.vault { + OnMainThread { + self?.requestName(title: "Name of template".localized, placeholder: "Search template".localized, completionHandler: { save, name in + if save { + if let name = name { + savedSearch.name = name + } + vault.add(savedSearch) + } + }) + } + } + + default: break + } + } else if let savedSearch = choice.representedObject as? OCSavedSearch { + self?.restore(savedSearch: savedSearch) + } + }) + savedSearchPopup?.choicesProvider = { [weak self] (_ popupController: PopupButtonController) in + var choices: [PopupButtonChoice] = [] + + if (self?.scope as? ItemSearchScope)?.canSaveSearch == true { + let saveSearchChoice = PopupButtonChoice(with: "Save as search view".localized, image: UIImage(systemName: "folder.badge.gearshape")?.withRenderingMode(.alwaysTemplate), representedObject: NSString("save-search")) + choices.append(saveSearchChoice) + } + + if (self?.scope as? ItemSearchScope)?.canSaveTemplate == true { + let saveTemplateChoice = PopupButtonChoice(with: "Save as search template".localized, image: UIImage(systemName: "plus.square.dashed")?.withRenderingMode(.alwaysTemplate), representedObject: NSString("save-template")) + choices.append(saveTemplateChoice) + } + + return choices + } + + var buttonConfiguration = UIButton.Configuration.plain().updated(for: savedSearchPopup!.button) + buttonConfiguration.image = UIImage(systemName: "ellipsis.circle")?.withRenderingMode(.alwaysTemplate) + buttonConfiguration.contentInsets = NSDirectionalEdgeInsets(top: 10, leading: 5, bottom: 10, trailing: 5) + buttonConfiguration.attributedTitle = nil + savedSearchPopup?.adaptButton = false + savedSearchPopup?.button.setAttributedTitle(nil, for: .normal) + savedSearchPopup?.button.configuration = buttonConfiguration + + rootView = UIView() + rootView?.translatesAutoresizingMaskIntoConstraints = false + + rootView?.addSubview(stackView!) + rootView?.addSubview(savedSearchPopup!.button) + + guard let stackView = stackView, let rootView = rootView, let savedSearchPopupButton = savedSearchPopup?.button else { return } + + NSLayoutConstraint.activate([ + stackView.leadingAnchor.constraint(equalTo: rootView.leadingAnchor), + stackView.trailingAnchor.constraint(lessThanOrEqualTo: savedSearchPopupButton.leadingAnchor), + stackView.topAnchor.constraint(equalTo: rootView.topAnchor), + stackView.bottomAnchor.constraint(equalTo: rootView.bottomAnchor), + + savedSearchPopupButton.topAnchor.constraint(equalTo: rootView.topAnchor), + savedSearchPopupButton.bottomAnchor.constraint(equalTo: rootView.bottomAnchor), + savedSearchPopupButton.trailingAnchor.constraint(equalTo: rootView.trailingAnchor) + ]) + + view = rootView + } + + override func viewDidLoad() { + categoryActiveButtonConfig = UIButton.Configuration.borderedTinted() + categoryActiveButtonConfig?.contentInsets.leading = 0 + categoryActiveButtonConfig?.contentInsets.trailing = 3 + + categoryUnusedButtonConfig = UIButton.Configuration.borderless() + categoryUnusedButtonConfig?.contentInsets.leading = 0 + categoryUnusedButtonConfig?.contentInsets.trailing = 3 + + createPopups() + + for category in categories { + if let button = category.popupController?.button { + stackView?.addArrangedSubview(button) + } + } + } + + var savedSearchPopup: PopupButtonController? + + func createPopups() { + // Create popups for all categories + for category in categories { + var choices : [PopupButtonChoice] = [] + + for queryCondition in category.options { + if let localizedDescription = queryCondition.localizedDescription { + let image : UIImage? = (queryCondition.symbolName != nil) ? UIImage(systemName: queryCondition.symbolName!)?.withRenderingMode(.alwaysTemplate) : nil + let choice = PopupButtonChoice(with: localizedDescription, image: image, representedObject: queryCondition) + choices.append(choice) + } + } + + let popupController = PopupButtonController(with: choices, dropDown: true, staticTitle: category.name, selectionCustomizer: { [weak self] (choice, isSelected) in + if let queryCondition = choice.representedObject as? OCQueryCondition, let searchElements = self?.searchElements { + return queryCondition.matchesWith(anyOf: searchElements) + } + return isSelected + }, choiceHandler: { [weak self, weak category] (choice, wasSelected) in + if let category = category, let queryCondition = choice.representedObject as? OCQueryCondition { + self?.handleSelection(of: queryCondition, in: category, wasSelected: wasSelected) + } + }) + + let button = popupController.button + button.addConstraint(button.heightAnchor.constraint(equalToConstant: 25)) + + category.popupController = popupController + } + } + + var searchElements: [SearchElement] = [] + + func handleSelection(of selectedOptionCondition: OCQueryCondition, in category: Category, wasSelected: Bool) { + var removeOptionConditions : [OCQueryCondition] = [] + var addOptionConditions : [OCQueryCondition] = [] + + // Determine whether / if any other options should be removed (f.ex. to implement mutually exclusive choices) + for option in category.options { + if category.shouldDeselect(option: option, when: selectedOptionCondition, isSelected: !wasSelected) { + removeOptionConditions.append(option) + } + } + + if !wasSelected { + // Option was freshly selected + addOptionConditions.append(selectedOptionCondition) + } else { + // Option was deselected + removeOptionConditions.append(selectedOptionCondition) + } + + for removeOptionToken in removeOptionConditions { + scope?.tokenizer?.remove(elementEquivalentTo: removeOptionToken) + } + + for addOptionToken in addOptionConditions { + if let searchToken = addOptionToken.generateSearchToken(fallbackText: "", inputComplete: true) { + scope?.tokenizer?.add(element: searchToken) + } + } + } + + func restore(savedSearch: OCSavedSearch) { + scope?.searchViewController?.restore(savedTemplate: savedSearch) + } + + func updateFor(_ searchElements: [SearchElement]) { + self.searchElements = searchElements + + // Hide saved search popup button + var showSavedSearchButton : Bool = false + if let searchScope = scope as? ItemSearchScope, searchScope.canSaveSearch || searchScope.canSaveTemplate { + showSavedSearchButton = true + } + savedSearchPopup?.button.isHidden = !showSavedSearchButton + + for category in categories { + var categoryHasMatch: Bool = false + + for optionCondition in category.options { + if optionCondition.matchesWith(anyOf: searchElements) { + categoryHasMatch = true + break + } + } + + if let categoryPopupButton = category.popupController?.button { + var buttonConfig : UIButton.Configuration? + + if categoryHasMatch { + buttonConfig = categoryActiveButtonConfig?.updated(for: categoryPopupButton) + } else { + buttonConfig = categoryUnusedButtonConfig?.updated(for: categoryPopupButton) + } + + if let attributedTitle = categoryPopupButton.currentAttributedTitle { + buttonConfig?.attributedTitle = AttributedString(attributedTitle) + } + categoryPopupButton.configuration = buttonConfig + } + + category.popupController?.button.sizeToFit() + } + } +} diff --git a/ownCloudAppShared/Client/Search/Item Search/Scopes/AccountSearchScope.swift b/ownCloudAppShared/Client/Search/Item Search/Scopes/AccountSearchScope.swift new file mode 100644 index 000000000..37362ce34 --- /dev/null +++ b/ownCloudAppShared/Client/Search/Item Search/Scopes/AccountSearchScope.swift @@ -0,0 +1,229 @@ +// +// AccountSearchScope.swift +// ownCloudAppShared +// +// Created by Felix Schwarz on 25.08.22. +// Copyright © 2022 ownCloud GmbH. All rights reserved. +// + +/* + * Copyright (C) 2022, ownCloud GmbH. + * + * This code is covered by the GNU Public License Version 3. + * + * For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ + * You should have received a copy of this license along with this program. If not, see . + * + */ + +import UIKit +import ownCloudSDK +import ownCloudApp + +// Search scope that creates and manages its own OCQuery using OCQueryConditions +// Used for server-wide search + +open class CustomQuerySearchScope : ItemSearchScope { + private let maxResultCountDefault = 100 // Maximum number of results to return from database (default) + private var maxResultCount = 100 // Maximum number of results to return from database (flexible) + + public override var isSelected: Bool { + didSet { + if isSelected { + resultActionSource.setItems([ + OCAction(title: "Show more results".localized, icon: nil, action: { [weak self] action, options, completion in + self?.showMoreResults() + completion(nil) + }) + ], updated: nil) + composeResultsDataSource() + } + } + } + + public var resultActionSource: OCDataSourceArray = OCDataSourceArray() + + var resultsSubscription: OCDataSourceSubscription? + + func composeResultsDataSource() { + if let queryResultsSource = customQuery?.queryResultsDataSource { + let composedResults = OCDataSourceComposition(sources: [ + queryResultsSource, + resultActionSource + ]) + + let maxResultCount = maxResultCount + let resultActionSource = resultActionSource + + resultsSubscription = queryResultsSource.subscribe(updateHandler: { [weak composedResults, weak resultActionSource] (subscription) in + let snapshot = subscription.snapshotResettingChangeTracking(true) + + if let resultActionSource = resultActionSource { + OnMainThread { + composedResults?.setInclude((snapshot.numberOfItems >= maxResultCount), for: resultActionSource) + } + } + }, on: .main, trackDifferences: false, performIntialUpdate: true) + + results = composedResults + } else { + results = nil + } + } + + public var customQuery: OCQuery? { + willSet { + if let core = clientContext.core, let oldQuery = customQuery { + core.stop(oldQuery) + } + } + + didSet { + if let core = clientContext.core, let newQuery = customQuery { + core.start(newQuery) + + composeResultsDataSource() + } else { + results = nil + } + } + } + + public var queryConditionModifier : ((OCQueryCondition?) -> OCQueryCondition?)? // MARK: modifier that can modify the query condition before it is passed to create the OCQuery backing the scope. The modification is invisible to the outside. Can be used to add constraints like limit to a drive, etc. + + public var additionalRequirementCondition: OCQueryCondition? // MARK: Adds a required additional condition to the baseCondition + + private var lastSearchTerm : String? + private var scrollToTopWithNextRefresh : Bool = false + + public func updateCustomSearchQuery() { + if lastSearchTerm != searchTerm { + // Reset max result count when search text changes + maxResultCount = maxResultCountDefault + lastSearchTerm = searchTerm + + // Scroll to top when search text changes + scrollToTopWithNextRefresh = true + } + + var condition = queryCondition + + if let additionalRequirementCondition = additionalRequirementCondition, let baseCondition = condition { + // Add additional requirement condition + condition = .require([additionalRequirementCondition, baseCondition]) + } + + if let queryConditionModifier = queryConditionModifier, let baseCondition = condition { + // Apply query condition modifier + condition = queryConditionModifier(baseCondition) + } + + if let condition = condition { + if let sortDescriptor = clientContext.sortDescriptor { + condition.sortBy = sortDescriptor.method.sortPropertyName + condition.sortAscending = sortDescriptor.direction == .ascendant + } + + condition.maxResultCount = NSNumber(value: maxResultCount) + + customQuery = OCQuery(condition:condition, inputFilter: nil) + } else { + customQuery = nil + } + } + + func showMoreResults() { + maxResultCount += maxResultCountDefault + updateCustomSearchQuery() + } + + open override var queryCondition: OCQueryCondition? { + didSet { + updateCustomSearchQuery() + } + } + + open override func sortDescriptorChanged(to sortDescriptor: SortDescriptor?) { + updateCustomSearchQuery() + } +} + +// Subclasses +open class AccountSearchScope : CustomQuerySearchScope { + public override init(with context: ClientContext, cellStyle: CollectionViewCellStyle?, localizedName name: String, localizedPlaceholder placeholder: String? = nil, icon: UIImage? = nil) { + var revealCellStyle : CollectionViewCellStyle? + + if let cellStyle = cellStyle { + revealCellStyle = CollectionViewCellStyle(from: cellStyle, changing: { cellStyle in + cellStyle.showRevealButton = true + }) + } + + super.init(with: context, cellStyle: revealCellStyle, localizedName: name, localizedPlaceholder: placeholder, icon: icon) + } + + open override var savedSearchScope: OCSavedSearchScope? { + return .account + } +} + +open class DriveSearchScope : AccountSearchScope { + private var driveID : String? + + public override init(with context: ClientContext, cellStyle: CollectionViewCellStyle?, localizedName name: String, localizedPlaceholder placeholder: String? = nil, icon: UIImage? = nil) { + super.init(with: context, cellStyle: cellStyle, localizedName: name, localizedPlaceholder: placeholder, icon: icon) + + if context.core?.useDrives == true, let driveID = context.drive?.identifier { + self.driveID = driveID + additionalRequirementCondition = .where(.driveID, isEqualTo: driveID) + } + } + + open override var savedSearchScope: OCSavedSearchScope? { + return .drive + } + + open override var savedSearch: AnyObject? { + if let savedSearch = super.savedSearch as? OCSavedSearch { + savedSearch.location = OCLocation(driveID: driveID, path: nil) + return savedSearch + } + return nil + } +} + +open class ContainerSearchScope: AccountSearchScope { + private var location : OCLocation? + + public override init(with context: ClientContext, cellStyle: CollectionViewCellStyle?, localizedName name: String, localizedPlaceholder placeholder: String? = nil, icon: UIImage? = nil) { + super.init(with: context, cellStyle: cellStyle, localizedName: name, localizedPlaceholder: placeholder, icon: icon) + + if context.core?.useDrives == true, let queryLocation = context.query?.queryLocation, let path = queryLocation.path { + self.location = queryLocation + + if context.core?.useDrives == true, let driveID = queryLocation.driveID { + additionalRequirementCondition = .require([ + .where(.driveID, isEqualTo: driveID), + .where(.path, startsWith: path) + ]) + } else { + additionalRequirementCondition = .require([ + .where(.path, startsWith: path) + ]) + } + } + } + + open override var savedSearchScope: OCSavedSearchScope? { + return .container + } + + open override var savedSearch: AnyObject? { + if let savedSearch = super.savedSearch as? OCSavedSearch { + savedSearch.location = location + return savedSearch + } + return nil + } + +} diff --git a/ownCloudAppShared/Client/Search/Item Search/Scopes/ItemSearchScope.swift b/ownCloudAppShared/Client/Search/Item Search/Scopes/ItemSearchScope.swift new file mode 100644 index 000000000..c1b54fcfa --- /dev/null +++ b/ownCloudAppShared/Client/Search/Item Search/Scopes/ItemSearchScope.swift @@ -0,0 +1,120 @@ +// +// ItemSearchScope.swift +// ownCloudAppShared +// +// Created by Felix Schwarz on 25.08.22. +// Copyright © 2022 ownCloud GmbH. All rights reserved. +// + +/* + * Copyright (C) 2022, ownCloud GmbH. + * + * This code is covered by the GNU Public License Version 3. + * + * For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ + * You should have received a copy of this license along with this program. If not, see . + * + */ + +import UIKit +import ownCloudSDK +import ownCloudApp + +// Common base class for query-modifying and custom query search scopes, implementing commonly used tools + +open class ItemSearchScope : SearchScope { + private var sortDescriptorObserver: NSKeyValueObservation? + + public override init(with context: ClientContext, cellStyle: CollectionViewCellStyle?, localizedName name: String, localizedPlaceholder placeholder: String? = nil, icon: UIImage? = nil) { + super.init(with: context, cellStyle: cellStyle, localizedName: name, localizedPlaceholder: placeholder, icon: icon) + + tokenizer = CustomQuerySearchTokenizer(scope: self, clientContext: context) + scopeViewController = ItemSearchSuggestionsViewController(with: self) + + sortDescriptorObserver = context.observe(\.sortDescriptor, changeHandler: { [weak self] context, change in + self?.sortDescriptorChanged(to: context.sortDescriptor) + }) + } + + deinit { + sortDescriptorObserver?.invalidate() + } + + open func sortDescriptorChanged(to sortDescriptor: SortDescriptor?) { + } + + open var queryCondition: OCQueryCondition? + + open override var isSelected: Bool { + didSet { + if !isSelected { + // Dump queryCondition and results + queryCondition = nil + results = nil + } + } + } + + open override func updateFor(_ searchElements: [SearchElement]) { + if isSelected { + var queryConditions : [OCQueryCondition] = [] + + for searchElement in searchElements { + if let queryCondition = searchElement.representedObject as? OCQueryCondition { + queryConditions.append(queryCondition) + } + } + + if queryConditions.count > 0 { + queryCondition = OCQueryCondition.require(queryConditions) + // Log.debug("Assembled search: \(queryCondition!.composedSearchTerm)") + } else { + queryCondition = nil + } + } + } + + open var searchTerm: String? { + return queryCondition?.composedSearchTerm + } + + // MARK: - Saved search support + // - ItemSearchScope specific + open var savedSearchScope: OCSavedSearchScope? { return nil } + + // - SearchScope subclassing + open override var canSaveSearch: Bool { + return (savedSearchScope != nil) && ((searchTerm?.count ?? 0) > 0) + } + open override var savedSearch: AnyObject? { + if let savedSearchScope = savedSearchScope, let searchTerm = searchTerm { + return OCSavedSearch(scope: savedSearchScope, location: nil, name: nil, isTemplate: false, searchTerm: searchTerm) + } + + return nil + } + open override var canSaveTemplate: Bool { + return canSaveSearch + } + open override var savedTemplate: AnyObject? { + if let savedTemplate = savedSearch as? OCSavedSearch { + savedTemplate.isTemplate = true + return savedTemplate + } + return nil + } + open override func canRestore(savedTemplate: AnyObject) -> Bool { + if let savedSearch = savedTemplate as? OCSavedSearch { + return savedSearch.scope == savedSearchScope + } + return false + } + open override func restore(savedTemplate: AnyObject) -> [SearchElement]? { + if let savedSearch = savedTemplate as? OCSavedSearch, + let elements = tokenizer?.parseSearchTerm(savedSearch.searchTerm, cursorOffset: nil, tokens: [], performUpdates: false) { + return elements + } + + return nil + } +} diff --git a/ownCloudAppShared/Client/Search/Item Search/Scopes/SingleFolderSearchScope.swift b/ownCloudAppShared/Client/Search/Item Search/Scopes/SingleFolderSearchScope.swift new file mode 100644 index 000000000..64f586f94 --- /dev/null +++ b/ownCloudAppShared/Client/Search/Item Search/Scopes/SingleFolderSearchScope.swift @@ -0,0 +1,87 @@ +// +// SingleFolderSearchScope.swift +// ownCloudAppShared +// +// Created by Felix Schwarz on 25.08.22. +// Copyright © 2022 ownCloud GmbH. All rights reserved. +// + +/* + * Copyright (C) 2022, ownCloud GmbH. + * + * This code is covered by the GNU Public License Version 3. + * + * For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ + * You should have received a copy of this license along with this program. If not, see . + * + */ + +import UIKit +import ownCloudSDK +import ownCloudApp + +// Search scope that modifies an existing OCQuery so that it only returns matching results +// Used for folder-only search + +open class QueryModifyingSearchScope : ItemSearchScope { + public override var isSelected: Bool { + didSet { + if let query = clientContext.query { + if isSelected { + // Modify existing query provided via clientContext + results = query.queryResultsDataSource + } + } + } + } + + open override var queryCondition: OCQueryCondition? { + didSet { + let queryCondition = queryCondition + + if let query = clientContext.query { + if queryCondition != nil { + let filterHandler: OCQueryFilterHandler = { (_, _, item) -> Bool in + if let item = item, let queryCondition = queryCondition { + return queryCondition.fulfilled(by: item) + } + return false + } + + if let filter = query.filter(withIdentifier: "text-search") { + query.updateFilter(filter, applyChanges: { filterToChange in + (filterToChange as? OCQueryFilter)?.filterHandler = filterHandler + }) + } else { + query.addFilter(OCQueryFilter(handler: filterHandler), withIdentifier: "text-search") + } + } else { + if let filter = query.filter(withIdentifier: "text-search") { + query.removeFilter(filter) + } + } + } + } + } +} + +// Subclass +open class SingleFolderSearchScope : QueryModifyingSearchScope { + open override var savedSearchScope: OCSavedSearchScope? { + return .folder + } + open override var canSaveSearch: Bool { + return false + } + open override var canSaveTemplate: Bool { + return super.canSaveSearch + } + open override var savedTemplate: AnyObject? { + if let savedTemplate = super.savedSearch as? OCSavedSearch { + savedTemplate.location = clientContext.query?.queryLocation + savedTemplate.isTemplate = true + return savedTemplate + } + return nil + } +} diff --git a/ownCloudAppShared/Client/Search/Item Search/Tokenizer/CustomQuerySearchTokenizer.swift b/ownCloudAppShared/Client/Search/Item Search/Tokenizer/CustomQuerySearchTokenizer.swift new file mode 100644 index 000000000..0869d3260 --- /dev/null +++ b/ownCloudAppShared/Client/Search/Item Search/Tokenizer/CustomQuerySearchTokenizer.swift @@ -0,0 +1,45 @@ +// +// CustomQuerySearchTokenizer.swift +// ownCloudAppShared +// +// Created by Felix Schwarz on 25.08.22. +// Copyright © 2022 ownCloud GmbH. All rights reserved. +// + +/* + * Copyright (C) 2022, ownCloud GmbH. + * + * This code is covered by the GNU Public License Version 3. + * + * For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ + * You should have received a copy of this license along with this program. If not, see . + * + */ + +import UIKit +import ownCloudApp + +open class CustomQuerySearchTokenizer : SearchTokenizer { + open override func shouldTokenize(segment: OCSearchSegment) -> SearchToken? { + // Determine if that parsing this segment would result in a non-itemname query condition + if let queryCondition = OCQueryCondition.fromSearchTerm(segment.segmentedString) { + if let property = queryCondition.property { + if (property != .name) || (queryCondition.operator != .propertyContains) { + // Non-itemname, property-based query condition -> generate search token + return queryCondition.generateSearchToken(fallbackText: segment.segmentedString, inputComplete: !segment.hasCursor) + } + } else { + // Non-itemname, logic-based query condition -> generate search token + return queryCondition.generateSearchToken(fallbackText: segment.segmentedString, inputComplete: !segment.hasCursor) + } + } + + // Do not generate a search token + return nil + } + + open override func composeTextElement(segment: OCSearchSegment) -> SearchElement { + // Compose search element with query condition representation + return SearchElement(text: segment.segmentedString, representedObject: OCQueryCondition.fromSearchTerm(segment.segmentedString), inputComplete: !segment.hasCursor) + } +} diff --git a/ownCloudAppShared/Client/Search/Item Search/Tokenizer/OCQueryCondition+SearchToken.swift b/ownCloudAppShared/Client/Search/Item Search/Tokenizer/OCQueryCondition+SearchToken.swift new file mode 100644 index 000000000..2298c9970 --- /dev/null +++ b/ownCloudAppShared/Client/Search/Item Search/Tokenizer/OCQueryCondition+SearchToken.swift @@ -0,0 +1,125 @@ +// +// OCQueryCondition+SearchToken.swift +// ownCloudAppShared +// +// Created by Felix Schwarz on 22.08.22. +// Copyright © 2022 ownCloud GmbH. All rights reserved. +// + +/* + * Copyright (C) 2022, ownCloud GmbH. + * + * This code is covered by the GNU Public License Version 3. + * + * For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ + * You should have received a copy of this license along with this program. If not, see . + * + */ + +import UIKit +import ownCloudSDK + +extension SearchElement { + func isEquivalent(to condition: OCQueryCondition) -> Bool { + if let token = self as? SearchToken, let tokenCondition = token.representedObject as? OCQueryCondition { + return tokenCondition.isEquivalent(to: condition) + } + + return false + } +} + +extension OCQueryCondition { + func isEquivalent(to condition: OCQueryCondition) -> Bool { + return (condition.localizedDescription == localizedDescription) && (condition.symbolName == symbolName) + } + + var firstNonLogicalCondition: OCQueryCondition? { + switch self.operator { + case .negate, .or, .and: + if let condition = self.value as? OCQueryCondition { + return condition + } + + default: + return self + } + + return self + } + + var firstDescriptiveCondition: OCQueryCondition? { + if localizedDescription != nil { + return self + } else { + if let condition = self.value as? OCQueryCondition { + return condition.firstDescriptiveCondition + } else if let conditions = self.value as? [OCQueryCondition] { + for condition in conditions { + if let descriptiveCondition = condition.firstDescriptiveCondition { + return descriptiveCondition + } + } + } + } + + return nil + } + + func generateSearchToken(fallbackText: String, inputComplete: Bool) -> SearchToken? { + // Use existing description and symbol + if let firstDescriptiveCondition = firstDescriptiveCondition, let localizedDescription = firstDescriptiveCondition.localizedDescription { + var icon : UIImage? + + if let symbolName = firstDescriptiveCondition.symbolName { + icon = UIImage(systemName: symbolName) + } + + return SearchToken(text: localizedDescription, icon: icon, representedObject: self, inputComplete: inputComplete) + } + + // Try to determine a useful icon and description + guard let effectiveCondition = firstNonLogicalCondition, let effectiveProperty = effectiveCondition.property else { + return nil + } + + let effectiveOperator = effectiveCondition.operator + var icon : UIImage? + + switch effectiveProperty { + case .name: + if effectiveOperator == .propertyHasSuffix { + icon = UIImage(systemName: "smallcircle.filled.circle") + } + + case .driveID: + icon = UIImage(systemName: "square.grid.2x2") + + case .mimeType: + icon = UIImage(systemName: "photo") + + case .size: + switch effectiveOperator { + case .propertyGreaterThanValue: + icon = UIImage(systemName: "greaterthan") + + case .propertyLessThanValue: + icon = UIImage(systemName: "lessthan") + + case .propertyEqualToValue: + icon = UIImage(systemName: "equal") + + default: break + } + + case .ownerUserName: break + + case .lastModified: + icon = UIImage(systemName: "calendar") + + default: break + } + + return SearchToken(text: localizedDescription ?? fallbackText, icon: icon, representedObject: self, inputComplete: inputComplete) + } +} diff --git a/ownCloudAppShared/Client/Search/Scopes/SearchScope.swift b/ownCloudAppShared/Client/Search/Scopes/SearchScope.swift index 5034be31c..6999e1cbf 100644 --- a/ownCloudAppShared/Client/Search/Scopes/SearchScope.swift +++ b/ownCloudAppShared/Client/Search/Scopes/SearchScope.swift @@ -19,233 +19,88 @@ import UIKit import ownCloudSDK -open class SearchScope: NSObject { +open class SearchScope: NSObject, SearchElementUpdating { public var localizedName : String + public var localizedPlaceholder: String? + public var icon : UIImage? @objc public dynamic var results: OCDataSource? @objc public dynamic var resultsCellStyle: CollectionViewCellStyle? public var isSelected: Bool = false + public weak var searchViewController: SearchViewController? public var clientContext: ClientContext + public var tokenizer: SearchTokenizer? // a search tokenizer must be set by subclasses in init() + public var scopeViewController: (UIViewController & SearchElementUpdating)? + static public func modifyingQuery(with context: ClientContext, localizedName: String) -> SearchScope { - return QueryModifyingSearchScope(with: context, cellStyle: nil, localizedName: localizedName) + return SingleFolderSearchScope(with: context, cellStyle: nil, localizedName: localizedName, localizedPlaceholder: "Search folder".localized, icon: UIImage(systemName: "folder")) } - static public func globalSearch(with context: ClientContext, cellStyle: CollectionViewCellStyle, localizedName: String) -> SearchScope { - let revealCellStyle = CollectionViewCellStyle(from: cellStyle, changing: { cellStyle in - cellStyle.showRevealButton = true - }) + static public func driveSearch(with context: ClientContext, cellStyle: CollectionViewCellStyle, localizedName: String) -> SearchScope { + var placeholder = "Search space".localized + if let driveName = context.drive?.name, driveName.count > 0 { + placeholder = "Search {{space.name}}".localized(["space.name" : driveName]) + } + return DriveSearchScope(with: context, cellStyle: cellStyle, localizedName: localizedName, localizedPlaceholder: placeholder, icon: UIImage(systemName: "square.grid.2x2")) + } - return CustomQuerySearchScope(with: context, cellStyle: revealCellStyle, localizedName: localizedName) + static public func containerSearch(with context: ClientContext, cellStyle: CollectionViewCellStyle, localizedName: String) -> SearchScope { + var placeholder = "Search tree".localized + if let path = context.query?.queryLocation?.lastPathComponent, path.count > 0 { + placeholder = "Search from {{folder.name}}".localized(["folder.name" : path]) + } + return ContainerSearchScope(with: context, cellStyle: cellStyle, localizedName: localizedName, localizedPlaceholder: placeholder, icon: UIImage(systemName: "square.stack.3d.up")) } - public init(with context: ClientContext, cellStyle: CollectionViewCellStyle?, localizedName name: String) { + static public func accountSearch(with context: ClientContext, cellStyle: CollectionViewCellStyle, localizedName: String) -> SearchScope { + return AccountSearchScope(with: context, cellStyle: cellStyle, localizedName: localizedName, localizedPlaceholder: "Search account".localized, icon: UIImage(systemName: "person")) + } + + public init(with context: ClientContext, cellStyle: CollectionViewCellStyle?, localizedName name: String, localizedPlaceholder placeholder: String? = nil, icon: UIImage? = nil) { clientContext = context localizedName = name super.init() resultsCellStyle = cellStyle + self.localizedPlaceholder = placeholder + self.icon = icon } - open func updateForSearchTerm(_ term: String?) { - + open func updateFor(_ searchElements: [SearchElement]) { } -} - -open class ItemSearchScope : SearchScope { - private var sortDescriptorObserver: NSKeyValueObservation? - public override init(with context: ClientContext, cellStyle: CollectionViewCellStyle?, localizedName name: String) { - super.init(with: context, cellStyle: cellStyle, localizedName: name) - - sortDescriptorObserver = context.observe(\.sortDescriptor, changeHandler: { [weak self] context, change in - self?.sortDescriptorChanged(to: context.sortDescriptor) - }) + // Save and restore searches + open var canSaveSearch: Bool { + // subclasses should return true if the scope can save the current search + return false } - deinit { - sortDescriptorObserver?.invalidate() + open var savedSearch: AnyObject? { + // subclasses should return an serializable object that can be used to restore the search if the scope can save the current search + return nil } - open func sortDescriptorChanged(to sortDescriptor: SortDescriptor?) { + open var canSaveTemplate: Bool { + // subclasses should return true if the scope can save the current search as template + return false } - open var queryCondition: OCQueryCondition? - - open override var isSelected: Bool { - didSet { - if !isSelected { - queryCondition = nil - results = nil - } - } + open var savedTemplate: AnyObject? { + // subclasses should return an serializable object that can be used to restore the search if the scope can save the current search as template + return nil } - open var searchTerm: String? - - open override func updateForSearchTerm(_ term: String?) { - if isSelected { - searchTerm = term - - if let searchText = term { - queryCondition = OCQueryCondition.fromSearchTerm(searchText) - } else { - queryCondition = nil - } - } - } -} - -open class QueryModifyingSearchScope : ItemSearchScope { - public override var isSelected: Bool { - didSet { - if let query = clientContext.query { - if isSelected { - // Modify existing query provided via clientContext - results = query.queryResultsDataSource - } - } - } - } - - open override var queryCondition: OCQueryCondition? { - didSet { - let queryCondition = queryCondition - - if let query = clientContext.query { - if queryCondition != nil { - let filterHandler: OCQueryFilterHandler = { (_, _, item) -> Bool in - if let item = item, let queryCondition = queryCondition { - return queryCondition.fulfilled(by: item) - } - return false - } - - if let filter = query.filter(withIdentifier: "text-search") { - query.updateFilter(filter, applyChanges: { filterToChange in - (filterToChange as? OCQueryFilter)?.filterHandler = filterHandler - }) - } else { - query.addFilter(OCQueryFilter(handler: filterHandler), withIdentifier: "text-search") - } - } else { - if let filter = query.filter(withIdentifier: "text-search") { - query.removeFilter(filter) - } - } - } - } - } -} - -open class CustomQuerySearchScope : ItemSearchScope { - private let maxResultCountDefault = 100 // Maximum number of results to return from database (default) - private var maxResultCount = 100 // Maximum number of results to return from database (flexible) - - public override var isSelected: Bool { - didSet { - if isSelected { - resultActionSource.setItems([ - OCAction(title: "Show more results".localized, icon: nil, action: { [weak self] action, options, completion in - self?.showMoreResults() - completion(nil) - }) - ], updated: nil) - composeResultsDataSource() - } - } - } - - public var resultActionSource: OCDataSourceArray = OCDataSourceArray() - - var resultsSubscription: OCDataSourceSubscription? - - func composeResultsDataSource() { - if let queryResultsSource = customQuery?.queryResultsDataSource { - let composedResults = OCDataSourceComposition(sources: [ - queryResultsSource, - resultActionSource - ]) - - let maxResultCount = maxResultCount - let resultActionSource = resultActionSource - - resultsSubscription = queryResultsSource.subscribe(updateHandler: { [weak composedResults, weak resultActionSource] (subscription) in - let snapshot = subscription.snapshotResettingChangeTracking(true) - - if let resultActionSource = resultActionSource { - OnMainThread { - composedResults?.setInclude((snapshot.numberOfItems >= maxResultCount), for: resultActionSource) - } - } - }, on: .main, trackDifferences: false, performIntialUpdate: true) - - results = composedResults - } else { - results = nil - } - } - - public var customQuery: OCQuery? { - willSet { - if let core = clientContext.core, let oldQuery = customQuery { - core.stop(oldQuery) - } - } - - didSet { - if let core = clientContext.core, let newQuery = customQuery { - core.start(newQuery) - - composeResultsDataSource() - } else { - results = nil - } - } - } - - private var lastSearchTerm : String? - private var scrollToTopWithNextRefresh : Bool = false - - public func updateCustomSearchQuery() { - if lastSearchTerm != searchTerm { - // Reset max result count when search text changes - maxResultCount = maxResultCountDefault - lastSearchTerm = searchTerm - - // Scroll to top when search text changes - scrollToTopWithNextRefresh = true - } - - if let condition = queryCondition { - if let sortDescriptor = clientContext.sortDescriptor { - condition.sortBy = sortDescriptor.method.sortPropertyName - condition.sortAscending = sortDescriptor.direction != .ascendant - } - - condition.maxResultCount = NSNumber(value: maxResultCount) - - customQuery = OCQuery(condition:condition, inputFilter: nil) - } else { - customQuery = nil - } - } - - func showMoreResults() { - maxResultCount += maxResultCountDefault - updateCustomSearchQuery() - } - - open override var queryCondition: OCQueryCondition? { - didSet { - updateCustomSearchQuery() - } + open func canRestore(savedTemplate: AnyObject) -> Bool { + // subclasses should return true if they can restore a saved template from the provided savedSearch object + return false } - open override func sortDescriptorChanged(to sortDescriptor: SortDescriptor?) { - updateCustomSearchQuery() + open func restore(savedTemplate: AnyObject) -> [SearchElement]? { + // subclasses should convert the saved template into search elements that can be used to popuplate f.ex. UISearchTextField + return nil } } diff --git a/ownCloudAppShared/Client/Search/SearchViewController.swift b/ownCloudAppShared/Client/Search/SearchViewController.swift index e11d7fc9f..919e24eb9 100644 --- a/ownCloudAppShared/Client/Search/SearchViewController.swift +++ b/ownCloudAppShared/Client/Search/SearchViewController.swift @@ -19,12 +19,20 @@ import UIKit import ownCloudSDK -public protocol SearchViewControllerDelegate : AnyObject { +public protocol SearchViewControllerDelegate: AnyObject { func searchBegan(for viewController: SearchViewController) - func search(for viewController: SearchViewController, withResults: OCDataSource?, style: CollectionViewCellStyle?) + func search(for viewController: SearchViewController, content: SearchViewController.Content?) func searchEnded(for viewController: SearchViewController) } +public protocol SearchElementUpdating: AnyObject { + func updateFor(_ searchElements: [SearchElement]) +} + +public protocol SearchViewControllerHost: AnyObject { + var searchViewController: SearchViewController? { get } +} + open class SearchViewController: UIViewController, UITextFieldDelegate, Themeable { private var resultsSourceObservation: NSKeyValueObservation? private var resultsCellStyleObservation: NSKeyValueObservation? @@ -35,13 +43,15 @@ open class SearchViewController: UIViewController, UITextFieldDelegate, Themeabl open var scopes: [SearchScope]? { didSet { - updateSegmentsFromScopes() + updateChoicesFromScopes() } } open var activeScope: SearchScope? { willSet { if activeScope != newValue { + activeScope?.tokenizer?.searchField = nil + activeScope?.searchViewController = nil activeScope?.isSelected = false resultsSourceObservation?.invalidate() @@ -53,51 +63,79 @@ open class SearchViewController: UIViewController, UITextFieldDelegate, Themeabl } didSet { - if let activeScope = activeScope, let scopeIndex = scopes?.firstIndex(of: activeScope) { + if let activeScope = activeScope, let activeScopeChoice = self.scopePopup?.choices?.first(where: { choice in + (choice.representedObject as? NSObject) == activeScope + }) { OnMainThread { - self.scopeView.selectedSegmentIndex = scopeIndex + self.scopePopup?.selectedChoice = activeScopeChoice } } if activeScope != oldValue { + activeScope?.tokenizer?.searchField = searchField activeScope?.isSelected = true + activeScope?.searchViewController = self resultsSourceObservation = activeScope?.observe(\.results, options: .initial, changeHandler: { [weak self] (scope, change) in if let self = self { - self.delegate?.search(for: self, withResults: self.activeScope?.results, style: self.activeScope?.resultsCellStyle) + self.updateScopeSearchResults(from: self.activeScope?.results, with: self.activeScope?.resultsCellStyle) } }) resultsCellStyleObservation = activeScope?.observe(\.resultsCellStyle, changeHandler: { [weak self] (scope, change) in if let self = self { - self.delegate?.search(for: self, withResults: self.activeScope?.results, style: self.activeScope?.resultsCellStyle) + self.updateScopeSearchResults(from: self.activeScope?.results, with: self.activeScope?.resultsCellStyle) } }) + searchField.placeholder = activeScope?.localizedPlaceholder + + scopeViewController = activeScope?.scopeViewController + sendSearchFieldContentsToActiveScope() } } } - private func updateSegmentsFromScopes() { - scopeView.removeAllSegments() + private func updateChoicesFromScopes() { + var choices : [PopupButtonChoice] = [] if let scopes = scopes { for scope in scopes { - scopeView.insertSegment(withTitle: scope.localizedName, at: scopeView.numberOfSegments, animated: false) + choices.append(PopupButtonChoice(with: scope.localizedName, image: scope.icon, representedObject: scope)) } } + + let previouslySelectedChoice = scopePopup?.selectedChoice + + scopePopup?.choices = choices + + if let previouslySelectedChoice = previouslySelectedChoice, let previouslyRepresentedObject = previouslySelectedChoice.representedObject as? NSObject, let equalSelectedChoice = choices.first(where: { choice in + if let representedObject = choice.representedObject as? NSObject { + return representedObject == previouslyRepresentedObject + } + + return false + }) { + scopePopup?.selectedChoice = equalSelectedChoice + } } - init(with clientContext: ClientContext, scopes: [SearchScope]?, targetNavigationItem: UINavigationItem? = nil, delegate: SearchViewControllerDelegate?) { + init(with clientContext: ClientContext, scopes: [SearchScope]?, targetNavigationItem: UINavigationItem? = nil, suggestionContent: Content? = nil, noResultContent: Content? = nil, delegate: SearchViewControllerDelegate?) { self.clientContext = clientContext super.init(nibName: nil, bundle: nil) self.targetNavigationItem = targetNavigationItem ?? clientContext.originatingViewController?.navigationItem + + self.suggestionContent = suggestionContent + self.noResultContent = noResultContent + self.delegate = delegate self.scopes = scopes + + updateCurrentContent() } required public init?(coder: NSCoder) { @@ -110,31 +148,57 @@ open class SearchViewController: UIViewController, UITextFieldDelegate, Themeabl // MARK: - Views var searchField: UISearchTextField = UISearchTextField() - var scopeView: UISegmentedControl = UISegmentedControl() + var scopePopup: PopupButtonController? + var scopeViewController: UIViewController? { + willSet { + scopeViewController?.willMove(toParent: nil) + scopeViewController?.view.removeFromSuperview() + scopeViewController?.removeFromParent() + + scopeViewControllerConstraints = nil + } + didSet { + if let scopeViewController = scopeViewController, let scopeViewControllerView = scopeViewController.view { + addChild(scopeViewController) + view.addSubview(scopeViewControllerView) + scopeViewControllerConstraints = [ + scopeViewControllerView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 5), + scopeViewControllerView.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor, constant: 10), + scopeViewControllerView.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor, constant: -10), + scopeViewControllerView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor, constant: -10) + ] + scopeViewController.didMove(toParent: self) + } + } + } + private var scopeViewControllerConstraints : [NSLayoutConstraint]? { + willSet { + if let scopeViewControllerConstraints = scopeViewControllerConstraints { + NSLayoutConstraint.deactivate(scopeViewControllerConstraints) + } + } + didSet { + if let scopeViewControllerConstraints = scopeViewControllerConstraints { + NSLayoutConstraint.activate(scopeViewControllerConstraints) + } + } + } open override func loadView() { let rootView = UIView() - scopeView.translatesAutoresizingMaskIntoConstraints = false - scopeView.addAction(UIAction(handler: { [weak self] action in - guard let self = self, let scopes = self.scopes else { - return - } - - let selectedIndex = self.scopeView.selectedSegmentIndex + scopePopup = PopupButtonController(with: [], selectedChoice: nil, choiceHandler: { [weak self] (choice, _) in + self?.activeScope = choice.representedObject as? SearchScope + }) + // scopePopup?.showTitleInButton = false - if selectedIndex >= 0, selectedIndex < scopes.count { - self.activeScope = scopes[selectedIndex] - } - }), for: .valueChanged) - rootView.addSubview(scopeView) + var scopePopupButtonConfiguration = UIButton.Configuration.borderless() + scopePopupButtonConfiguration.contentInsets.leading = 0 + scopePopupButtonConfiguration.contentInsets.trailing = 5 + scopePopup?.button.configuration = scopePopupButtonConfiguration NSLayoutConstraint.activate([ - scopeView.topAnchor.constraint(equalTo: rootView.safeAreaLayoutGuide.topAnchor, constant: 5), - scopeView.leadingAnchor.constraint(equalTo: rootView.safeAreaLayoutGuide.leadingAnchor, constant: 10), - scopeView.trailingAnchor.constraint(equalTo: rootView.safeAreaLayoutGuide.trailingAnchor, constant: -10), - scopeView.bottomAnchor.constraint(equalTo: rootView.safeAreaLayoutGuide.bottomAnchor, constant: -10), - + rootView.heightAnchor.constraint(equalToConstant: 10).with(priority: .defaultHigh), // Shrink to 10 points height if no scopeViewController is set searchField.widthAnchor.constraint(equalToConstant: 10000).with(priority: .defaultHigh) // maximize width of searchField in UINavigationBar ]) @@ -149,9 +213,13 @@ open class SearchViewController: UIViewController, UITextFieldDelegate, Themeabl searchField.addTarget(self, action: #selector(searchFieldContentsChanged), for: .editingChanged) searchField.delegate = self + if let scopesCount = scopes?.count, scopesCount > 1 { + searchField.leftView = scopePopup?.button + } + injectIntoNavigationItem() - updateSegmentsFromScopes() + updateChoicesFromScopes() delegate?.searchBegan(for: self) @@ -185,7 +253,8 @@ open class SearchViewController: UIViewController, UITextFieldDelegate, Themeabl // Overwrite content targetNavigationItem.titleView = searchField - let cancelToolbarButton = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(endSearch)) + // Alternative implementation as a standard "Cancel" button, more convention compliant, but needs more space: let cancelToolbarButton = UIBarButtonItem(barButtonSystemItem: .cancel, target: self, action: #selector(endSearch)) + let cancelToolbarButton = UIBarButtonItem(image: UIImage(systemName: "xmark"), style: .done, target: self, action: #selector(endSearch)) targetNavigationItem.rightBarButtonItems = [ cancelToolbarButton ] targetNavigationItem.hidesBackButton = true @@ -210,11 +279,102 @@ open class SearchViewController: UIViewController, UITextFieldDelegate, Themeabl @objc func searchFieldContentsChanged() { sendSearchFieldContentsToActiveScope() + updateCurrentContent() } func sendSearchFieldContentsToActiveScope() { - let searchText = searchField.text - self.activeScope?.updateForSearchTerm((searchText != "") ? searchText : nil) + self.activeScope?.tokenizer?.updateFor(searchField: searchField) + } + + // MARK: - Search results + // Content + public enum ContentType { + case suggestion + case noResults + case results + } + + public struct Content: Equatable { + var type: ContentType + var source: OCDataSource? + var style: CollectionViewCellStyle? + } + + open var suggestionContent: Content? { + didSet { + updateCurrentContent() + } + } + open var noResultContent: Content? { + didSet { + updateCurrentContent() + } + } + open var resultContent: Content? { + didSet { + updateCurrentContent() + } + } + + // Keeping track of scope's results and cell style + open var scopeResults: OCDataSource? { + willSet { + if newValue != scopeResults { + scopeResultsSubscription?.terminate() + scopeResultsSubscription = nil + } + } + + didSet { + if oldValue != scopeResults { + scopeResultsSubscription = scopeResults?.subscribe(updateHandler: { [weak self] (subscription) in + self?.scopeResultsItemCount = subscription.snapshotResettingChangeTracking(true).numberOfItems + }, on: .main, trackDifferences: false, performIntialUpdate: true) + } + } + } + var scopeResultsSubscription: OCDataSourceSubscription? + var scopeResultsItemCount: UInt = 0 { + didSet { + updateCurrentContent() + } + } + open var scopeResultsCellStyle: CollectionViewCellStyle? + + open func updateScopeSearchResults(from resultsDataSource: OCDataSource?, with resultsCellStyle: CollectionViewCellStyle?) { + scopeResultsCellStyle = resultsCellStyle + scopeResults = resultsDataSource + + resultContent = Content(type: .results, source: resultsDataSource, style: resultsCellStyle) + } + + // Determine current content + func updateCurrentContent() { + var searchFieldText = searchField.text ?? "" + + if searchFieldText.count > 0 { + // Strip white space and new lines (if pasted) to determine effective length of search term + let charSet = CharacterSet.whitespacesAndNewlines + searchFieldText = searchFieldText.trimmingCharacters(in: charSet) + } + + if searchField.tokens.count == 0, searchFieldText.count == 0 { + currentContent = suggestionContent + } else { + if scopeResultsItemCount == 0 { + currentContent = noResultContent + } else { + currentContent = resultContent + } + } + } + + private var currentContent: Content? { + didSet { + if oldValue != currentContent { + delegate?.search(for: self, content: currentContent) + } + } } // MARK: - End search @@ -223,12 +383,70 @@ open class SearchViewController: UIViewController, UITextFieldDelegate, Themeabl restoreNavigationItem() - delegate?.search(for: self, withResults: nil, style: nil) + delegate?.search(for: self, content: nil) delegate?.searchEnded(for: self) } + // MARK: - Restore template + public func canRestore(savedTemplate: AnyObject) -> Bool { + guard let scopes = scopes else { + return false + } + + let restoreScope: SearchScope? = scopes.first(where: { scope in + scope.canRestore(savedTemplate: savedTemplate) + }) + + return restoreScope != nil + } + + @discardableResult public func restore(savedTemplate: AnyObject) -> Bool { + guard let scopes = scopes else { + return false + } + + let restoreScope: SearchScope? = scopes.first(where: { scope in + scope.canRestore(savedTemplate: savedTemplate) + }) + + if let searchElements = restoreScope?.restore(savedTemplate: savedTemplate) { + setSearchFieldContent(from: searchElements) + + if activeScope != restoreScope { + activeScope = restoreScope + } else { + sendSearchFieldContentsToActiveScope() + } + + return true + } + + return false + } + + public func setSearchFieldContent(from searchElements: [SearchElement]) { + var tokens : [UISearchToken] = [] + var searchTerm : String = "" + + for searchElement in searchElements { + if let token = searchElement as? SearchToken { + tokens.append(token.uiSearchToken) + } else { + if searchTerm.count > 0 { + searchTerm += searchTerm + " " + searchElement.text + } else { + searchTerm = searchElement.text + } + } + } + + searchField.tokens = tokens + searchField.text = searchTerm + } + // MARK: - Theme support public func applyThemeCollection(theme: Theme, collection: ThemeCollection, event: ThemeEvent) { self.view.backgroundColor = collection.navigationBarColors.backgroundColor + searchField.applyThemeCollection(collection) } } diff --git a/ownCloudAppShared/Client/Search/Tokenizer/SearchElement.swift b/ownCloudAppShared/Client/Search/Tokenizer/SearchElement.swift new file mode 100644 index 000000000..c4f2d3781 --- /dev/null +++ b/ownCloudAppShared/Client/Search/Tokenizer/SearchElement.swift @@ -0,0 +1,58 @@ +// +// SearchElement.swift +// ownCloudAppShared +// +// Created by Felix Schwarz on 12.08.22. +// Copyright © 2022 ownCloud GmbH. All rights reserved. +// + +/* + * Copyright (C) 2022, ownCloud GmbH. + * + * This code is covered by the GNU Public License Version 3. + * + * For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ + * You should have received a copy of this license along with this program. If not, see . + * + */ + +import UIKit + +open class SearchElement: NSObject { + open var text: String + open var inputComplete: Bool + + open var representedObject: AnyObject? + + required public init(text: String, representedObject: AnyObject? = nil, inputComplete: Bool) { + self.text = text + self.inputComplete = inputComplete + + super.init() + + self.representedObject = representedObject + } +} + +open class SearchToken: SearchElement { + open var icon: UIImage? + + required public init(text: String, icon: UIImage?, representedObject: AnyObject?, inputComplete: Bool) { + super.init(text: text, representedObject: representedObject, inputComplete: inputComplete) + + self.icon = icon + } + + required public init(text: String, representedObject: AnyObject? = nil, inputComplete: Bool) { + fatalError("init(text:representedObject:inputComplete:) has not been implemented") + } +} + +extension SearchToken { + var uiSearchToken: UISearchToken { + let token = UISearchToken(icon: icon, text: text) + token.representedObject = self + + return token + } +} diff --git a/ownCloudAppShared/Client/Search/Tokenizer/SearchTokenizer.swift b/ownCloudAppShared/Client/Search/Tokenizer/SearchTokenizer.swift new file mode 100644 index 000000000..27d52132c --- /dev/null +++ b/ownCloudAppShared/Client/Search/Tokenizer/SearchTokenizer.swift @@ -0,0 +1,174 @@ +// +// SearchTokenizer.swift +// ownCloudAppShared +// +// Created by Felix Schwarz on 12.08.22. +// Copyright © 2022 ownCloud GmbH. All rights reserved. +// + +/* + * Copyright (C) 2022, ownCloud GmbH. + * + * This code is covered by the GNU Public License Version 3. + * + * For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ + * You should have received a copy of this license along with this program. If not, see . + * + */ + +import UIKit +import ownCloudApp + +// Base search tokenizer class +open class SearchTokenizer: NSObject { + weak var scope: SearchScope? + public var clientContext: ClientContext? + + weak var searchField: UISearchTextField? + + public init(scope: SearchScope, clientContext: ClientContext?) { + super.init() + + self.scope = scope + self.clientContext = clientContext + } + + open func updateFor(searchField: UISearchTextField) { + let searchText = searchField.text + let cursorOffset = searchField.cursorPositionInTextualRange + + self.searchField = searchField + + var searchTokens : [SearchToken] = [] + + for token in searchField.tokens { + if let searchToken = token.representedObject as? SearchToken { + searchTokens.append(searchToken) + } + } + + parseSearchTerm((searchText != "") ? searchText : nil, cursorOffset: cursorOffset, tokens: searchTokens) + } + + @discardableResult + open func parseSearchTerm(_ term: String?, cursorOffset: Int?, tokens: [SearchToken], performUpdates: Bool = true) -> [SearchElement] { + var assembledTokens : [SearchToken] = [] + var assembledElements : [SearchElement] = [] + + // Find terms and tokens in provided searchTerm + if let searchSegments = term?.segmentedForSearch(withQuotationMarks: false, cursorPosition: (cursorOffset as? NSNumber)) { + Log.log("SearchSegments: \(String.init(describing: searchSegments))") + + for searchSegment in searchSegments.reversed() { // Iterate segments in reverse so that replacing a segment doesn't change its position + if !searchSegment.hasCursor, let token = shouldTokenize(segment: searchSegment) { + // Create token from search segment and insert it position 0 (remember: we're iterating segments in reverse!) + assembledTokens.insert(token, at: 0) + if performUpdates { + replace(segment: searchSegment, with: token) + } + } else { + // Create search term text element and insert it position 0 (remember: we're iterating segments in reverse!) + assembledElements.insert(composeTextElement(segment: searchSegment), at: 0) + } + } + } + + // Insert existing tokens at the front of the found tokens + assembledTokens.insert(contentsOf: tokens, at: 0) + + // Insert tokens in front of elements + assembledElements.insert(contentsOf: assembledTokens, at: 0) + + // Tell scope to update for the provided elements + if performUpdates { + scope?.updateFor(assembledElements) + } + + // Tell scope's scope view controller to update for the provided elements + if performUpdates { + scope?.scopeViewController?.updateFor(assembledElements) + } + + return assembledElements + } + + // MARK: Token management + open func add(element: SearchElement) { + if let token = element as? SearchToken, let searchField = searchField { + searchField.insertToken(token.uiSearchToken, at: searchField.tokens.count) + } + + setNeedsUpdate() + } + + open func remove(elementEquivalentTo queryCondition: OCQueryCondition) { + if let searchTokens = searchField?.tokens, let searchField = searchField { + var tokenIndex = 0 + for searchToken in searchTokens { + if let representedToken = searchToken.representedObject as? SearchElement, let representedQueryCondition = representedToken.representedObject as? OCQueryCondition { + if queryCondition.isEquivalent(to: representedQueryCondition) { + searchField.removeToken(at: tokenIndex) + break + } + } + + tokenIndex += 1 + } + + setNeedsUpdate() + } + } + + open func remove(element: SearchElement) { + if let representedQueryCondition = element.representedObject as? OCQueryCondition { + remove(elementEquivalentTo: representedQueryCondition) + } + } + + // MARK: Efficient updating + private var _needsUpdate : Bool = false + + func setNeedsUpdate() { + OCSynchronized(self, block: { + _needsUpdate = true + OnMainThread { [weak self] in + self?._updateIfNeeded() + } + }) + } + + private func _updateIfNeeded() { + var doUpdate : Bool = false + + OCSynchronized(self, block: { + doUpdate = _needsUpdate + _needsUpdate = false + }) + + if doUpdate, let searchField = searchField { + updateFor(searchField: searchField) + } + } + + // MARK: UISearchToken generation + open func replace(segment: OCSearchSegment, with searchToken: SearchToken) { + var replaceRange = segment.range + replaceRange.length += 1 // remove trailing space as well as the spaces accumulate otherwise + + if let replaceRange = searchField?.textRange(from: replaceRange) { + searchField?.replace(replaceRange, withText: "") + searchField?.insertToken(searchToken.uiSearchToken, at: searchField?.tokens.count ?? 0) + } + } + + // MARK: - Conversion to token & text element + open func shouldTokenize(segment: OCSearchSegment) -> SearchToken? { + // No tokenization + return nil + } + + open func composeTextElement(segment: OCSearchSegment) -> SearchElement { + // Conversion of text elements in SearchElements + return SearchElement(text: segment.segmentedString, representedObject: nil, inputComplete: !segment.hasCursor) + } +} diff --git a/ownCloudAppShared/Client/User Interface/ComposedMessageView.swift b/ownCloudAppShared/Client/User Interface/ComposedMessageView.swift new file mode 100644 index 000000000..0b77fbb66 --- /dev/null +++ b/ownCloudAppShared/Client/User Interface/ComposedMessageView.swift @@ -0,0 +1,514 @@ +// +// ComposedMessageView.swift +// ownCloudAppShared +// +// Created by Felix Schwarz on 20.09.22. +// Copyright © 2022 ownCloud GmbH. All rights reserved. +// + +/* + * Copyright (C) 2022, ownCloud GmbH. + * + * This code is covered by the GNU Public License Version 3. + * + * For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ + * You should have received a copy of this license along with this program. If not, see . + * + */ + +import UIKit + +public class ComposedMessageElement: NSObject { + public enum Kind { + case title + case subtitle + case text + case image(image: UIImage?, imageSize: CGSize?, adaptSizeToRatio: Bool) + case divider + case progressBar(progress: Progress? = nil, relativeWidth: CGFloat = 1.0) + case progressCircle(progress: Progress? = nil) + case activityIndicator(style: UIActivityIndicatorView.Style = .medium, size: CGSize) + case spacing(size: CGFloat) + } + + public enum Alignment { + case leading + case trailing + case centered + } + + public var kind: Kind + public var alignment: Alignment + + public var text: String? + public var font: UIFont? + public var style: ThemeItemStyle? + public var textView: UILabel? + + public var imageView: UIImageView? + + public var progress: Progress? + public var progressBar: UIProgressView? + + public var activityIndicatorView: UIActivityIndicatorView? + + public var insets: NSDirectionalEdgeInsets = NSDirectionalEdgeInsets(top: 5, leading: 5, bottom: 5, trailing: 5) + + private var _view: UIView? + public var view: UIView? { + if _view == nil { + switch kind { + case .title, .subtitle, .text: + textView = UILabel() + textView?.translatesAutoresizingMaskIntoConstraints = false + textView?.numberOfLines = 0 + + switch alignment { + case .leading: textView?.textAlignment = .left + case .trailing: textView?.textAlignment = .right + case .centered: textView?.textAlignment = .center + } + + textView?.text = text + textView?.setContentCompressionResistancePriority(.required, for: .vertical) + textView?.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + textView?.setContentHuggingPriority(.required, for: .vertical) + + add(applier: { [weak self] theme, collection, event in + if let self = self, let style = self.style, let label = self.textView { + label.applyThemeCollection(collection, itemStyle: style, itemState: .normal) + } + }) + + _view = textView + + case .image(let image, let imageSize, let adaptSizeToRatio): + imageView = UIImageView(image: image) + imageView?.contentMode = .scaleAspectFit + imageView?.translatesAutoresizingMaskIntoConstraints = false + + let rootView = UIView() + rootView.translatesAutoresizingMaskIntoConstraints = false + rootView.addSubview(imageView!) + + var constraints: [NSLayoutConstraint] = [ + imageView!.topAnchor.constraint(equalTo: rootView.topAnchor), + imageView!.bottomAnchor.constraint(equalTo: rootView.bottomAnchor) + ] + + var renderImageSize = imageSize + + if adaptSizeToRatio, let imageSize = imageSize, let image = image { + let ratioSize = image.size + + if imageSize.width == 0 { + renderImageSize?.width = ratioSize.height * ratioSize.width / imageSize.height + + } else if imageSize.height == 0 { + renderImageSize?.width = ratioSize.width * ratioSize.height / imageSize.width + } + } + + if let imageSize = renderImageSize { + if imageSize.width != 0 { + constraints.append(imageView!.widthAnchor.constraint(equalToConstant: imageSize.width)) + } + if imageSize.height != 0 { + constraints.append(imageView!.heightAnchor.constraint(equalToConstant: imageSize.height)) + } + } + + switch alignment { + case .leading: + constraints.append(contentsOf: [ + imageView!.leadingAnchor.constraint(equalTo: rootView.leadingAnchor), + imageView!.trailingAnchor.constraint(lessThanOrEqualTo: rootView.trailingAnchor) + ]) + + case .trailing: + constraints.append(contentsOf: [ + imageView!.leadingAnchor.constraint(greaterThanOrEqualTo: rootView.leadingAnchor), + imageView!.trailingAnchor.constraint(equalTo: rootView.trailingAnchor) + ]) + + case .centered: + constraints.append(contentsOf: [ + imageView!.leadingAnchor.constraint(greaterThanOrEqualTo: rootView.leadingAnchor), + imageView!.centerXAnchor.constraint(equalTo: rootView.centerXAnchor), + imageView!.trailingAnchor.constraint(lessThanOrEqualTo: rootView.trailingAnchor) + ]) + } + + NSLayoutConstraint.activate(constraints) + + _view = rootView + + case .divider: + let dividerView = UIView() + dividerView.translatesAutoresizingMaskIntoConstraints = false + dividerView.heightAnchor.constraint(equalToConstant: 1).isActive = true + dividerView.backgroundColor = UIColor(red: 0, green: 0, blue: 0, alpha: 0.2) + _view = dividerView + + case .progressBar(let progress, let relativeWidth): + let progressView = UIProgressView(progressViewStyle: .default) + progressView.translatesAutoresizingMaskIntoConstraints = false + progressView.observedProgress = progress + + let rootView = UIView() + rootView.translatesAutoresizingMaskIntoConstraints = false + rootView.addSubview(progressView) + + var constraints: [NSLayoutConstraint] = [ + progressView.topAnchor.constraint(equalTo: rootView.topAnchor), + progressView.bottomAnchor.constraint(equalTo: rootView.bottomAnchor), + progressView.widthAnchor.constraint(equalTo: rootView.widthAnchor, multiplier: relativeWidth) + ] + + switch alignment { + case .leading: + constraints.append(contentsOf: [ + progressView.leadingAnchor.constraint(equalTo: rootView.leadingAnchor) + ]) + + case .trailing: + constraints.append(contentsOf: [ + progressView.trailingAnchor.constraint(equalTo: rootView.trailingAnchor) + ]) + + case .centered: + constraints.append(contentsOf: [ + progressView.centerXAnchor.constraint(equalTo: rootView.centerXAnchor) + ]) + } + + NSLayoutConstraint.activate(constraints) + + progressBar = progressView + _view = rootView + + case .progressCircle(let progress): + let progressView = ProgressView() + progressView.translatesAutoresizingMaskIntoConstraints = false + progressView.progress = progress + + let rootView = UIView() + rootView.translatesAutoresizingMaskIntoConstraints = false + rootView.addSubview(progressView) + + var constraints: [NSLayoutConstraint] = [ + progressView.topAnchor.constraint(equalTo: rootView.topAnchor), + progressView.bottomAnchor.constraint(equalTo: rootView.bottomAnchor) + ] + + switch alignment { + case .leading: + constraints.append(contentsOf: [ + progressView.leadingAnchor.constraint(equalTo: rootView.leadingAnchor) + ]) + + case .trailing: + constraints.append(contentsOf: [ + progressView.trailingAnchor.constraint(equalTo: rootView.trailingAnchor) + ]) + + case .centered: + constraints.append(contentsOf: [ + progressView.centerXAnchor.constraint(equalTo: rootView.centerXAnchor) + ]) + } + + NSLayoutConstraint.activate(constraints) + + _view = rootView + + case .activityIndicator(let style, let size): + let activityIndicator = UIActivityIndicatorView(style: style) + activityIndicator.translatesAutoresizingMaskIntoConstraints = false + + let rootView = UIView() + rootView.translatesAutoresizingMaskIntoConstraints = false + rootView.addSubview(activityIndicator) + + var constraints: [NSLayoutConstraint] = [ + activityIndicator.topAnchor.constraint(equalTo: rootView.topAnchor), + activityIndicator.bottomAnchor.constraint(equalTo: rootView.bottomAnchor), + activityIndicator.widthAnchor.constraint(equalToConstant: size.width), + activityIndicator.heightAnchor.constraint(equalToConstant: size.height) + ] + + switch alignment { + case .leading: + constraints.append(contentsOf: [ + activityIndicator.leadingAnchor.constraint(equalTo: rootView.leadingAnchor) + ]) + + case .trailing: + constraints.append(contentsOf: [ + activityIndicator.trailingAnchor.constraint(equalTo: rootView.trailingAnchor) + ]) + + case .centered: + constraints.append(contentsOf: [ + activityIndicator.centerXAnchor.constraint(equalTo: rootView.centerXAnchor) + ]) + } + + NSLayoutConstraint.activate(constraints) + + activityIndicatorView = activityIndicator + _view = rootView + + case .spacing(let spacing): + let spacingView = UIView() + spacingView.translatesAutoresizingMaskIntoConstraints = false + spacingView.heightAnchor.constraint(equalToConstant: spacing).isActive = true + + _view = spacingView + } + } + + return _view + } + + public var elementInView: Bool = false { + didSet { + switch kind { + case .activityIndicator(_, _): + if elementInView { + activityIndicatorView?.startAnimating() + } else { + activityIndicatorView?.stopAnimating() + } + + default: break + } + } + } + + private var themeAppliers : [ThemeApplier] = [] + private func add(applier: @escaping ThemeApplier) { + themeAppliers.append(applier) + } + func apply(theme: Theme, collection: ThemeCollection, event: ThemeEvent) { + for applier in themeAppliers { + applier(theme, collection, event) + } + } + + init(kind: Kind, alignment: Alignment, insets altInsets: NSDirectionalEdgeInsets? = nil) { + self.kind = kind + self.alignment = alignment + super.init() + + if let altInsets = altInsets { + insets = altInsets + } + } + + static public func title(_ title: String, alignment: Alignment = .leading, insets altInsets: NSDirectionalEdgeInsets? = nil) -> ComposedMessageElement { + let element = ComposedMessageElement(kind: .title, alignment: alignment, insets: altInsets) + element.text = title + element.style = .system(textStyle: .title3, weight: .bold) + + return element + } + + static public func subtitle(_ subtitle: String, alignment: Alignment = .leading, insets altInsets: NSDirectionalEdgeInsets? = nil) -> ComposedMessageElement { + let element = ComposedMessageElement(kind: .subtitle, alignment: alignment, insets: altInsets) + element.text = subtitle + element.style = .systemSecondary(textStyle: .body, weight: nil) + + return element + } + + static public func text(_ text: String, font: UIFont? = nil, style: ThemeItemStyle?, alignment: Alignment = .leading, insets altInsets: NSDirectionalEdgeInsets? = nil) -> ComposedMessageElement { + let element = ComposedMessageElement(kind: .text, alignment: alignment, insets: altInsets) + element.text = text + element.font = font + element.style = style + + return element + } + + static public func image(_ image: UIImage, size: CGSize?, adaptSizeToRatio: Bool = false, alignment: Alignment = .centered, insets altInsets: NSDirectionalEdgeInsets? = nil) -> ComposedMessageElement { + return ComposedMessageElement(kind: .image(image: image, imageSize: size, adaptSizeToRatio: adaptSizeToRatio), alignment: alignment, insets: altInsets) + } + + static public func progressBar(with relativeWidth: CGFloat = 1.0, progress: Progress?, alignment: Alignment = .centered, insets altInsets: NSDirectionalEdgeInsets? = nil) -> ComposedMessageElement { + let element = ComposedMessageElement(kind: .progressBar(progress: progress, relativeWidth: relativeWidth), alignment: alignment, insets: altInsets) + element.progress = progress + + return element + } + + static public func progressCircle(with progress: Progress, alignment: Alignment = .centered, insets altInsets: NSDirectionalEdgeInsets? = nil) -> ComposedMessageElement { + let element = ComposedMessageElement(kind: .progressCircle(progress: progress), alignment: alignment, insets: altInsets) + element.progress = progress + + return element + } + + static public func activityIndicator(with alignment: Alignment = .centered, style: UIActivityIndicatorView.Style? = nil, size: CGSize = CGSize(width: 30, height: 30), insets altInsets: NSDirectionalEdgeInsets? = nil) -> ComposedMessageElement { + let effectiveStyle = style ?? Theme.shared.activeCollection.activityIndicatorViewStyle + return ComposedMessageElement(kind: .activityIndicator(style: effectiveStyle, size: size), alignment: alignment, insets: altInsets) + } + + static public func spacing(_ spacing: CGFloat) -> ComposedMessageElement { + let element = ComposedMessageElement(kind: .spacing(size: spacing), alignment: .centered) + element.insets = .zero + + return element + } + + public static var divider: ComposedMessageElement { + return ComposedMessageElement(kind: .divider, alignment: .centered) + } +} + +public class ComposedMessageView: UIView, Themeable { + open var elements: [ComposedMessageElement]? + open var elementInsets: NSDirectionalEdgeInsets = NSDirectionalEdgeInsets(top: 20, leading: 20, bottom: 20, trailing: 20) + + open var backgroundInsets: NSDirectionalEdgeInsets = .zero { + didSet { + layoutBackgroundView() + } + } + open var backgroundView: UIView? { + willSet { + backgroundView?.removeFromSuperview() + } + didSet { + layoutBackgroundView() + } + } + + init(elements: [ComposedMessageElement]) { + super.init(frame: .zero) + self.translatesAutoresizingMaskIntoConstraints = false + + self.elements = elements + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + Theme.shared.unregister(client: self) + } + + private var _didSetupContent: Bool = false + public override func willMove(toSuperview newSuperview: UIView?) { + if !_didSetupContent { + _didSetupContent = true + + embedAndLayoutElements() + + Theme.shared.register(client: self, applyImmediately: true) + } + + super.willMove(toSuperview: newSuperview) + } + + public override func willMove(toWindow newWindow: UIWindow?) { + super.willMove(toWindow: newWindow) + + guard let elements = elements else { return } + + for element in elements { + element.elementInView = (newWindow != nil) + } + } + + func layoutBackgroundView() { + if let view = backgroundView { + view.removeFromSuperview() // Removes all constraints + insertSubview(view, at: 0) // (Re)insert view below all other subviews + + NSLayoutConstraint.activate([ + view.leadingAnchor.constraint(equalTo: leadingAnchor, constant: backgroundInsets.leading), + view.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -backgroundInsets.trailing), + view.topAnchor.constraint(equalTo: topAnchor, constant: backgroundInsets.top), + view.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -backgroundInsets.bottom) + ]) + } + } + + func embedAndLayoutElements() { + if let elements = elements { + var previousElement: ComposedMessageElement? + var constraints: [NSLayoutConstraint] = [] + + for element in elements { + if let view = element.view { + addSubview(view) + + constraints.append(contentsOf: [ + view.leadingAnchor.constraint(equalTo: self.leadingAnchor, constant: (element.insets.leading + elementInsets.leading)), + view.trailingAnchor.constraint(equalTo: self.trailingAnchor, constant: -(element.insets.trailing + elementInsets.trailing)) + ]) + + if let previousElement = previousElement, let previousView = previousElement.view { + constraints.append(contentsOf: [ + view.topAnchor.constraint(equalTo: previousView.bottomAnchor, constant: element.insets.top + previousElement.insets.bottom) + ]) + } else { + constraints.append(contentsOf: [ + view.topAnchor.constraint(equalTo: self.topAnchor, constant: (element.insets.top + elementInsets.top)) + ]) + } + + previousElement = element + } + } + + if let previousElement = previousElement, let previousView = previousElement.view { + constraints.append(contentsOf: [ + previousView.bottomAnchor.constraint(equalTo: self.bottomAnchor, constant: -(previousElement.insets.bottom + elementInsets.bottom)) + ]) + } + + NSLayoutConstraint.activate(constraints) + } + } + + public func applyThemeCollection(theme: Theme, collection: ThemeCollection, event: ThemeEvent) { + if let elements = elements { + for element in elements { + element.apply(theme: theme, collection: collection, event: event) + } + } + } +} + +public extension ComposedMessageView { + static func infoBox(image: UIImage? = nil, title: String? = nil, subtitle: String? = nil, additionalElements: [ComposedMessageElement]? = nil) -> ComposedMessageView { + var elements: [ComposedMessageElement] = [] + + if let image = image { + let imageElement: ComposedMessageElement = .image(image, size: CGSize(width: 48, height: 48), alignment: .centered) + imageElement.insets = NSDirectionalEdgeInsets(top: 0, leading: 10, bottom: 10, trailing: 10) + elements.append(imageElement) + } + + if let title = title { + elements.append(.title(title, alignment: .centered)) + } + + if let subtitle = subtitle { + elements.append(.subtitle(subtitle, alignment: .centered)) + } + + let infoBoxView = ComposedMessageView(elements: elements) + infoBoxView.elementInsets = NSDirectionalEdgeInsets(top: 30, leading: 20, bottom: 30, trailing: 20) + infoBoxView.backgroundInsets = NSDirectionalEdgeInsets(top: 10, leading: 0, bottom: 10, trailing: 0) + infoBoxView.backgroundView = RoundCornerBackgroundView(fillColorPicker: { theme, collection, event in + return collection.tableGroupBackgroundColor + }) + infoBoxView.translatesAutoresizingMaskIntoConstraints = false + + return infoBoxView + } +} diff --git a/ownCloudAppShared/Client/User Interface/GradientView.swift b/ownCloudAppShared/Client/User Interface/GradientView.swift index 4dcd6a4e4..7d461417f 100644 --- a/ownCloudAppShared/Client/User Interface/GradientView.swift +++ b/ownCloudAppShared/Client/User Interface/GradientView.swift @@ -19,6 +19,24 @@ import UIKit public class GradientView : UIView { + public enum Direction { + case vertical + case horizontal + + var startPoint: CGPoint { + switch self { + case .vertical: return CGPoint(x: 0.5, y: 0.0) + case .horizontal: return CGPoint(x: 0.0, y: 0.5) + } + } + var endPoint: CGPoint { + switch self { + case .vertical: return CGPoint(x: 0.5, y: 1.0) + case .horizontal: return CGPoint(x: 1.0, y: 0.5) + } + } + } + public var colors: [CGColor] { didSet { gradientLayer?.colors = colors @@ -32,7 +50,7 @@ public class GradientView : UIView { var gradientLayer : CAGradientLayer? - public init(with colors: [CGColor], locations: [NSNumber]) { + public init(with colors: [CGColor], locations: [NSNumber], direction: Direction = .vertical) { self.colors = colors self.locations = locations @@ -43,6 +61,8 @@ public class GradientView : UIView { gradientLayer = CAGradientLayer() gradientLayer?.colors = colors gradientLayer?.locations = locations + gradientLayer?.startPoint = direction.startPoint + gradientLayer?.endPoint = direction.endPoint } required public init?(coder: NSCoder) { diff --git a/ownCloudAppShared/Client/User Interface/PopupButtonController.swift b/ownCloudAppShared/Client/User Interface/PopupButtonController.swift new file mode 100644 index 000000000..83a2c8d12 --- /dev/null +++ b/ownCloudAppShared/Client/User Interface/PopupButtonController.swift @@ -0,0 +1,191 @@ +// +// UIButton+PopupButton.swift +// ownCloudAppShared +// +// Created by Felix Schwarz on 25.08.22. +// Copyright © 2022 ownCloud GmbH. All rights reserved. +// + +/* + * Copyright (C) 2022, ownCloud GmbH. + * + * This code is covered by the GNU Public License Version 3. + * + * For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ + * You should have received a copy of this license along with this program. If not, see . + * + */ + +import UIKit + +open class PopupButtonChoice : NSObject { + var image: UIImage? + + var title: String + var buttonTitle: String? + var buttonAccessibilityLabel: String? + + var representedObject: AnyObject? + + init(with title: String, image: UIImage?, buttonTitle: String? = nil, buttonAccessibilityLabel: String? = nil, representedObject: AnyObject? = nil) { + self.title = title + super.init() + + self.image = image + self.buttonTitle = buttonTitle + self.buttonAccessibilityLabel = buttonAccessibilityLabel + self.representedObject = representedObject + } +} + +open class PopupButtonController : NSObject { + typealias TitleCustomizer = (_ choice: PopupButtonChoice, _ isSelected: Bool) -> String + typealias SelectionCustomizer = (_ choice: PopupButtonChoice, _ isSelected: Bool) -> Bool + typealias ChoiceHandler = (_ choice: PopupButtonChoice, _ wasSelected: Bool) -> Void + typealias DynamicChoicesProvider = (_ popupController: PopupButtonController) -> [PopupButtonChoice] + + var button : UIButton + + private var _choices : [PopupButtonChoice]? + var choices : [PopupButtonChoice]? { + get { + if let choicesProvider = choicesProvider { + return choicesProvider(self) + } + return _choices + } + set { + _choices = newValue + } + } + var choicesProvider: DynamicChoicesProvider? + var selectedChoice : PopupButtonChoice? { + didSet { + _updateTitleFromSelectedChoice() + } + } + var isDropDown : Bool = true + var showTitleInButton: Bool = true { + didSet { + _updateTitleFromSelectedChoice() + } + } + var showImageInButton: Bool = true { + didSet { + _updateTitleFromSelectedChoice() + } + } + + var titleCustomizer: TitleCustomizer? + var selectionCustomizer: SelectionCustomizer? + var choiceHandler: ChoiceHandler? + + var staticTitle: String? { + didSet { + _updateTitleFromSelectedChoice() + } + } + var adaptButton: Bool = true + + override init() { + button = UIButton(type: .system) + + super.init() + + button.translatesAutoresizingMaskIntoConstraints = false + + button.titleLabel?.font = UIFont.preferredFont(forTextStyle: .subheadline) + button.titleLabel?.adjustsFontForContentSizeCategory = true + + button.setContentHuggingPriority(.required, for: .horizontal) + + button.showsMenuAsPrimaryAction = true + } + + convenience init(with choices: [PopupButtonChoice], selectedChoice: PopupButtonChoice? = nil, selectFirstChoice: Bool = false, dropDown: Bool = false, staticTitle: String? = nil, titleCustomizer: TitleCustomizer? = nil, selectionCustomizer: SelectionCustomizer? = nil, choiceHandler: ChoiceHandler? = nil) { + self.init() + + self.choices = choices + self.selectedChoice = selectedChoice ?? (selectFirstChoice ? choices.first : nil) + + self.titleCustomizer = titleCustomizer + self.selectionCustomizer = selectionCustomizer + self.choiceHandler = choiceHandler + + self.staticTitle = staticTitle + self.isDropDown = dropDown + + button.menu = UIMenu(title: "", children: [ + UIDeferredMenuElement.uncached({ [weak self] completion in + var menuItems : [UIMenuElement] = [] + guard let choices = self?.choices else { + completion(menuItems) + return + } + + for choice in choices { + var isSelectedChoice : Bool = (self?.isDropDown == false) ? (self?.selectedChoice == choice) : false + var title = choice.title + + if let selectionCustomizer = self?.selectionCustomizer { + isSelectedChoice = selectionCustomizer(choice, isSelectedChoice) + } + + if let titleCustomizer = self?.titleCustomizer { + title = titleCustomizer(choice, isSelectedChoice) + } + + let menuItem = UIAction(title: title, image: choice.image, attributes: [], state: isSelectedChoice ? .on : .off) { [weak self] _ in + self?.selectedChoice = choice + self?.choiceHandler?(choice, isSelectedChoice) + } + + menuItems.append(menuItem) + } + + completion(menuItems) + }) + ]) + + _updateTitleFromSelectedChoice() + } + + private func _updateTitleFromSelectedChoice() { + var title = staticTitle + + if let selectedChoice = selectedChoice, !isDropDown { + if staticTitle == nil { + title = selectedChoice.buttonTitle ?? selectedChoice.title + + if let titleCustomizer = titleCustomizer { + title = titleCustomizer(selectedChoice, true) + } + } + + if adaptButton { + if showImageInButton { + button.setImage(selectedChoice.image, for: .normal) + } + button.accessibilityLabel = selectedChoice.buttonAccessibilityLabel + } + } + + let chevronAttachment = NSTextAttachment() + chevronAttachment.image = UIImage(named: "chevron-small-light")?.withRenderingMode(.alwaysTemplate) + // Alternative using SF Symbols (but too strong IMO): chevronAttachment.image = UIImage(systemName: "chevron.down")?.withRenderingMode(.alwaysTemplate) + let chevronString = NSAttributedString(attachment: chevronAttachment) + + let attributedTitle = NSMutableAttributedString(string: (title != nil) && showTitleInButton ? " \(title!) " : " ") + + if adaptButton { + if button.effectiveUserInterfaceLayoutDirection == .leftToRight { + attributedTitle.append(chevronString) + } else { + attributedTitle.insert(chevronString, at: 0) + } + + button.setAttributedTitle(attributedTitle, for: .normal) + button.sizeToFit() + } + } +} diff --git a/ownCloudAppShared/Client/User Interface/RoundCornerBackgroundView.swift b/ownCloudAppShared/Client/User Interface/RoundCornerBackgroundView.swift new file mode 100644 index 000000000..6c5c9e38a --- /dev/null +++ b/ownCloudAppShared/Client/User Interface/RoundCornerBackgroundView.swift @@ -0,0 +1,89 @@ +// +// RoundCornerBackgroundView.swift +// ownCloudAppShared +// +// Created by Felix Schwarz on 21.09.22. +// Copyright © 2022 ownCloud GmbH. All rights reserved. +// + +/* + * Copyright (C) 2022, ownCloud GmbH. + * + * This code is covered by the GNU Public License Version 3. + * + * For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ + * You should have received a copy of this license along with this program. If not, see . + * + */ + +import UIKit + +public struct CornerRadius { + var radii: CGSize + var corners: UIRectCorner + + static let standard : CornerRadius = .identical(with: 10) + + static func identical(with radius: CGFloat) -> CornerRadius { + return CornerRadius(radii: CGSize(width: radius, height: radius), corners: .allCorners) + } + static func topOnly(with radius: CGFloat) -> CornerRadius { + return CornerRadius(radii: CGSize(width: radius, height: radius), corners: [.topLeft, .topRight]) + } + static func bottomOnly(with radius: CGFloat) -> CornerRadius { + return CornerRadius(radii: CGSize(width: radius, height: radius), corners: [.bottomLeft, .bottomRight]) + } +} + +public class RoundCornerBackgroundView: UIView, Themeable { + var cornerRadius: CornerRadius { + didSet { + setNeedsDisplay() + } + } + var fillColor: UIColor { + didSet { + setNeedsDisplay() + } + } + var fillColorPicker: ThemeColorPicker? { + didSet { + setNeedsDisplay() + } + } + + typealias ThemeColorPicker = (_ theme: Theme, _ collection: ThemeCollection, _ event: ThemeEvent) -> UIColor? + + init(with radius: CornerRadius = .standard, fillColor: UIColor = .systemGroupedBackground, fillColorPicker: ThemeColorPicker? = nil) { + self.fillColor = fillColor + self.cornerRadius = radius + + super.init(frame: .zero) + translatesAutoresizingMaskIntoConstraints = false + isOpaque = false + + self.fillColorPicker = fillColorPicker + + if fillColorPicker != nil { + Theme.shared.register(client: self, applyImmediately: true) + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func draw(_ rect: CGRect) { + let bezierPath = UIBezierPath(roundedRect: bounds, byRoundingCorners: cornerRadius.corners, cornerRadii: cornerRadius.radii) + fillColor.setFill() + bezierPath.fill() + } + + public func applyThemeCollection(theme: Theme, collection: ThemeCollection, event: ThemeEvent) { + if let fillColorPicker = fillColorPicker { + if let pickedColor = fillColorPicker(theme, collection, event) { + fillColor = pickedColor + } + } + } +} diff --git a/ownCloudAppShared/Client/User Interface/SortBar.swift b/ownCloudAppShared/Client/User Interface/SortBar.swift index 042c23326..fd350ca2f 100644 --- a/ownCloudAppShared/Client/User Interface/SortBar.swift +++ b/ownCloudAppShared/Client/User Interface/SortBar.swift @@ -80,8 +80,8 @@ public class SortBar: UIView, Themeable, UIPopoverPresentationControllerDelegate let rightPadding: CGFloat = 20.0 let rightSelectButtonPadding: CGFloat = 8.0 let rightSearchScopePadding: CGFloat = 15.0 - let topPadding: CGFloat = 10.0 - let bottomPadding: CGFloat = 10.0 + let topPadding: CGFloat = 2.0 + let bottomPadding: CGFloat = 2.0 // MARK: - Instance variables. diff --git a/ownCloudAppShared/SDK Extensions/OCItem+Extension.swift b/ownCloudAppShared/SDK Extensions/OCItem+Extension.swift index 8151bd532..dfb081a12 100644 --- a/ownCloudAppShared/SDK Extensions/OCItem+Extension.swift +++ b/ownCloudAppShared/SDK Extensions/OCItem+Extension.swift @@ -18,6 +18,7 @@ import UIKit import ownCloudSDK +import ownCloudApp import CoreServices import UniformTypeIdentifiers @@ -34,87 +35,7 @@ extension OCItem { } static private let iconNamesByMIMEType : [String:String] = { - var mimeTypeToIconMap : [String:String] = [ - // List taken from https://github.com/owncloud/core/blob/master/core/js/mimetypelist.js - "application/coreldraw": "image", - "application/epub+zip": "text", - "application/font-sfnt": "image", - "application/font-woff": "image", - "application/illustrator": "image", - "application/javascript": "text/code", - "application/json": "text/code", - "application/msaccess": "file", - "application/msexcel": "x-office/spreadsheet", - "application/msonenote": "x-office/document", - "application/mspowerpoint": "x-office/presentation", - "application/msword": "x-office/document", - "application/octet-stream": "file", - "application/postscript": "image", - "application/rss+xml": "application/xml", - "application/vnd.android.package-archive": "package/x-generic", - "application/vnd.lotus-wordpro": "x-office/document", - "application/vnd.ms-excel": "x-office/spreadsheet", - "application/vnd.ms-excel.addin.macroEnabled.12": "x-office/spreadsheet", - "application/vnd.ms-excel.sheet.binary.macroEnabled.12": "x-office/spreadsheet", - "application/vnd.ms-excel.sheet.macroEnabled.12": "x-office/spreadsheet", - "application/vnd.ms-excel.template.macroEnabled.12": "x-office/spreadsheet", - "application/vnd.ms-fontobject": "image", - "application/vnd.ms-powerpoint": "x-office/presentation", - "application/vnd.ms-powerpoint.addin.macroEnabled.12": "x-office/presentation", - "application/vnd.ms-powerpoint.presentation.macroEnabled.12": "x-office/presentation", - "application/vnd.ms-powerpoint.slideshow.macroEnabled.12": "x-office/presentation", - "application/vnd.ms-powerpoint.template.macroEnabled.12": "x-office/presentation", - "application/vnd.ms-word.document.macroEnabled.12": "x-office/document", - "application/vnd.ms-word.template.macroEnabled.12": "x-office/document", - "application/vnd.oasis.opendocument.presentation": "x-office/presentation", - "application/vnd.oasis.opendocument.presentation-template": "x-office/presentation", - "application/vnd.oasis.opendocument.spreadsheet": "x-office/spreadsheet", - "application/vnd.oasis.opendocument.spreadsheet-template": "x-office/spreadsheet", - "application/vnd.oasis.opendocument.text": "x-office/document", - "application/vnd.oasis.opendocument.text-master": "x-office/document", - "application/vnd.oasis.opendocument.text-template": "x-office/document", - "application/vnd.oasis.opendocument.text-web": "x-office/document", - "application/vnd.openxmlformats-officedocument.presentationml.presentation": "x-office/presentation", - "application/vnd.openxmlformats-officedocument.presentationml.slideshow": "x-office/presentation", - "application/vnd.openxmlformats-officedocument.presentationml.template": "x-office/presentation", - "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": "x-office/spreadsheet", - "application/vnd.openxmlformats-officedocument.spreadsheetml.template": "x-office/spreadsheet", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document": "x-office/document", - "application/vnd.openxmlformats-officedocument.wordprocessingml.template": "x-office/document", - "application/vnd.visio": "x-office/document", - "application/vnd.wordperfect": "x-office/document", - "application/x-7z-compressed": "package/x-generic", - "application/x-bzip2": "package/x-generic", - "application/x-cbr": "text", - "application/x-compressed": "package/x-generic", - "application/x-dcraw": "image", - "application/x-deb": "package/x-generic", - "application/x-font": "image", - "application/x-gimp": "image", - "application/x-gzip": "package/x-generic", - "application/x-perl": "text/code", - "application/x-photoshop": "image", - "application/x-php": "text/code", - "application/x-rar-compressed": "package/x-generic", - "application/x-tar": "package/x-generic", - "application/x-tex": "text", - "application/xml": "text/html", - "application/yaml": "text/code", - "application/zip": "package/x-generic", - "database": "file", - "httpd/unix-directory": "dir", - "message/rfc822": "text", - "text/css": "text/code", - "text/csv": "x-office/spreadsheet", - "text/html": "text/code", - "text/x-c": "text/code", - "text/x-c++src": "text/code", - "text/x-h": "text/code", - "text/x-java-source": "text/code", - "text/x-python": "text/code", - "text/x-shellscript": "text/code", - "web": "text/code" - ] + var mimeTypeToIconMap: [String:String] = OCItem.mimeTypeToAliasesMap mimeTypeToIconMap.keys.forEach { mimeTypeKey in var mimeType : String? = mimeTypeToIconMap[mimeTypeKey] diff --git a/ownCloudAppShared/UIKit Extension/UITextField+Extension.swift b/ownCloudAppShared/UIKit Extension/UITextField+Extension.swift new file mode 100644 index 000000000..5a9b194c6 --- /dev/null +++ b/ownCloudAppShared/UIKit Extension/UITextField+Extension.swift @@ -0,0 +1,47 @@ +// +// UITextField+Extension.swift +// ownCloudAppShared +// +// Created by Felix Schwarz on 09.08.22. +// Copyright © 2022 ownCloud GmbH. All rights reserved. +// + +/* + * Copyright (C) 2022, ownCloud GmbH. + * + * This code is covered by the GNU Public License Version 3. + * + * For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ + * You should have received a copy of this license along with this program. If not, see . + * + */ + +import UIKit + +public extension UITextField { + var cursorPosition : Int? { + if let selectedTextRange = selectedTextRange, selectedTextRange.isEmpty { + return offset(from: beginningOfDocument, to: selectedTextRange.start) + } + return nil + } +} + +public extension UISearchTextField { + var cursorPositionInTextualRange : Int? { + if let selectedTextRange = selectedTextRange, selectedTextRange.isEmpty { + return offset(from: textualRange.start, to: selectedTextRange.start) + } + return nil + } + + func textRange(from range: NSRange) -> UITextRange? { + let textualRange = textualRange + if let startPosition = position(from: textualRange.start, offset: range.location), + let endPosition = position(from: startPosition, in: .right, offset: range.length) { + return textRange(from: startPosition, to: endPosition) + } + + return nil + } +} diff --git a/ownCloudAppShared/UIKit Extension/UIView+OCDataItem.swift b/ownCloudAppShared/UIKit Extension/UIView+OCDataItem.swift index b0173802c..e828c2596 100644 --- a/ownCloudAppShared/UIKit Extension/UIView+OCDataItem.swift +++ b/ownCloudAppShared/UIKit Extension/UIView+OCDataItem.swift @@ -20,12 +20,20 @@ import UIKit import ownCloudSDK extension UIView : OCDataItem, OCDataItemVersioning { + static var viewUUIDKey = "_ocViewUUIDKey" + public var dataItemType: OCDataItemType { return .view } public var dataItemReference: OCDataItemReference { - return NSString(format: "%p", self) + if let uuid = objc_getAssociatedObject(self, &UIView.viewUUIDKey) as? NSString { + return uuid + } else { + let uuid = UUID().uuidString as NSString + objc_setAssociatedObject(self, &UIView.viewUUIDKey, uuid, .OBJC_ASSOCIATION_RETAIN) + return uuid + } } public var dataItemVersion: OCDataItemVersion { diff --git a/ownCloudAppShared/User Interface/SegmentView/SegmentView.swift b/ownCloudAppShared/User Interface/SegmentView/SegmentView.swift new file mode 100644 index 000000000..7fb35ec3b --- /dev/null +++ b/ownCloudAppShared/User Interface/SegmentView/SegmentView.swift @@ -0,0 +1,152 @@ +// +// SegmentView.swift +// ownCloudAppShared +// +// Created by Felix Schwarz on 29.09.22. +// Copyright © 2022 ownCloud GmbH. All rights reserved. +// + +/* + * Copyright (C) 2022, ownCloud GmbH. + * + * This code is covered by the GNU Public License Version 3. + * + * For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ + * You should have received a copy of this license along with this program. If not, see . + * + */ + +import UIKit + +public class SegmentView: ThemeView { + public enum TruncationMode { + case none + case clipTail + case truncateHead + case truncateTail + } + + open var items: [SegmentViewItem] { + didSet { + if superview != nil { + recreateAndLayoutItemViews() + } + } + } + open var itemSpacing: CGFloat = 5 + open var truncationMode: TruncationMode = .none + open var insets: NSDirectionalEdgeInsets = NSDirectionalEdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0) + + public init(with items: [SegmentViewItem], truncationMode: TruncationMode) { + self.items = items + + super.init() + + self.truncationMode = truncationMode + isOpaque = false + } + + required public init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func composeMaskView(leading: Bool) -> UIView { + let fadeInFromLeft: Bool = (effectiveUserInterfaceLayoutDirection == .leftToRight) ? leading : !leading + let gradientColors: [CGColor] = [ + CGColor(red: 0, green: 0, blue: 0, alpha: fadeInFromLeft ? 0.0 : 1.0), + CGColor(red: 0, green: 0, blue: 0, alpha: fadeInFromLeft ? 1.0 : 0.0) + ] + let rootView = UIView(frame: bounds) + let fillView = UIView() + let gradientWidth : CGFloat = 20 + let gradientView = GradientView(with: gradientColors, locations: [0, 1], direction: .horizontal) + + fillView.backgroundColor = .black + + fillView.translatesAutoresizingMaskIntoConstraints = false + gradientView.translatesAutoresizingMaskIntoConstraints = false + + var constraints: [NSLayoutConstraint] = [ + fillView.topAnchor.constraint(equalTo: rootView.topAnchor), + fillView.bottomAnchor.constraint(equalTo: rootView.bottomAnchor), + gradientView.topAnchor.constraint(equalTo: rootView.topAnchor), + gradientView.bottomAnchor.constraint(equalTo: rootView.bottomAnchor), + gradientView.widthAnchor.constraint(equalToConstant: gradientWidth) + ] + + rootView.addSubview(fillView) + rootView.addSubview(gradientView) + + if fadeInFromLeft { + constraints.append(contentsOf: [ + gradientView.leftAnchor.constraint(equalTo: rootView.leftAnchor), + gradientView.rightAnchor.constraint(equalTo: fillView.leftAnchor), + fillView.rightAnchor.constraint(equalTo: rootView.rightAnchor) + ]) + } else { + constraints.append(contentsOf: [ + fillView.leftAnchor.constraint(equalTo: rootView.leftAnchor), + fillView.rightAnchor.constraint(equalTo: gradientView.leftAnchor), + gradientView.rightAnchor.constraint(equalTo: rootView.rightAnchor) + ]) + } + + NSLayoutConstraint.activate(constraints) + + return rootView + } + + private var itemViews: [UIView] = [] + + override open func setupSubviews() { + super.setupSubviews() + recreateAndLayoutItemViews() + } + + func recreateAndLayoutItemViews() { + // Remove existing views + for itemView in itemViews { + itemView.removeFromSuperview() + } + + itemViews.removeAll() + + // Create new views + for item in items { + if let view = item.view { + itemViews.append(view) + } + } + + // Embed + embedHorizontally(views: itemViews, insets: insets, spacingProvider: { _, _ in + return self.itemSpacing + }, constraintsModifier: { constraintSet in + // Implement truncation + masking + var maskView: UIView? + + switch self.truncationMode { + case .none: break + + case .clipTail: + constraintSet.lastTrailingConstraint?.priority = .defaultHigh + + case .truncateHead: + constraintSet.firstLeadingConstraint?.priority = .defaultHigh + maskView = self.composeMaskView(leading: true) + + case .truncateTail: + constraintSet.lastTrailingConstraint?.priority = .defaultHigh + maskView = self.composeMaskView(leading: false) + } + + if let maskView = maskView { + maskView.translatesAutoresizingMaskIntoConstraints = false + self.embed(toFillWith: maskView) + self.mask = maskView + } + + return constraintSet + }) + } +} diff --git a/ownCloudAppShared/User Interface/SegmentView/SegmentViewItem.swift b/ownCloudAppShared/User Interface/SegmentView/SegmentViewItem.swift new file mode 100644 index 000000000..4cb983315 --- /dev/null +++ b/ownCloudAppShared/User Interface/SegmentView/SegmentViewItem.swift @@ -0,0 +1,65 @@ +// +// SegmentViewItem.swift +// ownCloudAppShared +// +// Created by Felix Schwarz on 29.09.22. +// Copyright © 2022 ownCloud GmbH. All rights reserved. +// + +/* + * Copyright (C) 2022, ownCloud GmbH. + * + * This code is covered by the GNU Public License Version 3. + * + * For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ + * You should have received a copy of this license along with this program. If not, see . + * + */ + +import UIKit + +public class SegmentViewItem: NSObject { + public enum CornerStyle { + case sharp + case round(points: CGFloat) + } + + public enum Style { + case plain + case label + case token + } + + open var style: Style + open var icon: UIImage? + open var title: String? + open var titleTextStyle: UIFont.TextStyle? + + open var representedObject: AnyObject? + open weak var weakRepresentedObject: AnyObject? + + open var iconTitleSpacing: CGFloat = 2 + open var insets: NSDirectionalEdgeInsets = NSDirectionalEdgeInsets(top: 3, leading: 5, bottom: 3, trailing: 5) + open var cornerStyle: CornerStyle? + + var _view: UIView? + open var view: UIView? { + if _view == nil { + _view = SegmentViewItemView(with: self) + _view?.translatesAutoresizingMaskIntoConstraints = false + } + return _view + } + + public init(with icon: UIImage? = nil, title: String? = nil, style: Style = .plain, titleTextStyle: UIFont.TextStyle? = nil, representedObject: AnyObject? = nil, weakRepresentedObject: AnyObject? = nil) { + self.style = style + + super.init() + + self.icon = icon + self.title = title + self.titleTextStyle = titleTextStyle + self.representedObject = representedObject + self.weakRepresentedObject = weakRepresentedObject + } +} diff --git a/ownCloudAppShared/User Interface/SegmentView/SegmentViewItemView.swift b/ownCloudAppShared/User Interface/SegmentView/SegmentViewItemView.swift new file mode 100644 index 000000000..5f80a0ee7 --- /dev/null +++ b/ownCloudAppShared/User Interface/SegmentView/SegmentViewItemView.swift @@ -0,0 +1,108 @@ +// +// SegmentViewItemView.swift +// ownCloudAppShared +// +// Created by Felix Schwarz on 29.09.22. +// Copyright © 2022 ownCloud GmbH. All rights reserved. +// + +/* + * Copyright (C) 2022, ownCloud GmbH. + * + * This code is covered by the GNU Public License Version 3. + * + * For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ + * You should have received a copy of this license along with this program. If not, see . + * + */ + +import UIKit + +public class SegmentViewItemView: ThemeView { + var item: SegmentViewItem + + var iconView: UIImageView? + var titleView: UILabel? + + public init(with item: SegmentViewItem) { + self.item = item + + super.init() + + isOpaque = false + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + open override func setupSubviews() { + compose() + } + + open func compose() { + let rootView = self + var views : [UIView] = [] + + rootView.setContentHuggingPriority(.required, for: .horizontal) + rootView.setContentHuggingPriority(.required, for: .vertical) + + if let icon = item.icon { + iconView = UIImageView(image: icon) + iconView?.contentMode = .scaleAspectFit + iconView?.translatesAutoresizingMaskIntoConstraints = false + iconView?.setContentHuggingPriority(.required, for: .horizontal) + iconView?.setContentHuggingPriority(.required, for: .vertical) + iconView?.setContentCompressionResistancePriority(.required, for: .horizontal) + iconView?.setContentCompressionResistancePriority(.required, for: .vertical) + views.append(iconView!) + } + + if let title = item.title { + titleView = UILabel() + titleView?.translatesAutoresizingMaskIntoConstraints = false + titleView?.text = title + if let titleTextStyle = item.titleTextStyle { + titleView?.font = .preferredFont(forTextStyle: titleTextStyle) + } + titleView?.setContentHuggingPriority(.required, for: .horizontal) + titleView?.setContentHuggingPriority(.required, for: .vertical) + titleView?.setContentCompressionResistancePriority(.required, for: .vertical) + titleView?.setContentCompressionResistancePriority(.required, for: .horizontal) + + views.append(titleView!) + } + + embedHorizontally(views: views, insets: item.insets, spacingProvider: { leadingView, trailingView in + if trailingView == self.titleView, leadingView == self.iconView { + return self.item.iconTitleSpacing + } + + return nil + }) + + switch item.cornerStyle { + case .none, .sharp: + layer.cornerRadius = 0 + + case .round(let points): + layer.cornerRadius = points + } + } + + public override func applyThemeCollection(theme: Theme, collection: ThemeCollection, event: ThemeEvent) { + super.applyThemeCollection(theme: theme, collection: collection, event: event) + + switch item.style { + case .plain, .label: + iconView?.tintColor = collection.tableRowColors.symbolColor + titleView?.textColor = collection.tableRowColors.secondaryLabelColor + backgroundColor = .clear + + case .token: + iconView?.tintColor = collection.tokenColors.normal.foreground + titleView?.textColor = collection.tokenColors.normal.foreground + backgroundColor = collection.tokenColors.normal.background + } + } +} diff --git a/ownCloudAppShared/User Interface/SegmentView/UIView+EmbedAndLayout.swift b/ownCloudAppShared/User Interface/SegmentView/UIView+EmbedAndLayout.swift new file mode 100644 index 000000000..138ae4256 --- /dev/null +++ b/ownCloudAppShared/User Interface/SegmentView/UIView+EmbedAndLayout.swift @@ -0,0 +1,99 @@ +// +// UIView+EmbedAndLayout.swift +// ownCloudAppShared +// +// Created by Felix Schwarz on 29.09.22. +// Copyright © 2022 ownCloud GmbH. All rights reserved. +// + +/* + * Copyright (C) 2022, ownCloud GmbH. + * + * This code is covered by the GNU Public License Version 3. + * + * For distribution utilizing Apple mechanisms please see https://owncloud.org/contribute/iOS-license-exception/ + * You should have received a copy of this license along with this program. If not, see . + * + */ + +import UIKit + +public extension UIView { + typealias SpacingProvider = (_ leadingView: UIView, _ trailingView: UIView) -> CGFloat? + typealias ConstraintsModifier = (_ constraintSet: HorizontalConstraintSet) -> HorizontalConstraintSet + + struct HorizontalConstraintSet { + var firstLeadingConstraint: NSLayoutConstraint? + var lastTrailingConstraint: NSLayoutConstraint? + } + + @discardableResult func embedHorizontally(views: [UIView], insets: NSDirectionalEdgeInsets, spacingProvider: SpacingProvider? = nil, constraintsModifier: ConstraintsModifier? = nil) -> HorizontalConstraintSet { + var viewIdx : Int = 0 + var previousView: UIView? + var embedConstraints: [NSLayoutConstraint] = [] + + var constraintSet: HorizontalConstraintSet = HorizontalConstraintSet() + + for view in views { + var leadingConstraint: NSLayoutConstraint? + + // Create leading constraint + if viewIdx == 0 { + leadingConstraint = view.leadingAnchor.constraint(equalTo: leadingAnchor, constant: insets.leading) + constraintSet.firstLeadingConstraint = leadingConstraint + } else if let previousView = previousView { + let spacing : CGFloat = spacingProvider?(previousView, view) ?? 0 + leadingConstraint = view.leadingAnchor.constraint(equalTo: previousView.trailingAnchor, constant: spacing) + } + + // Add constraints + // - leading + if let leadingConstraint = leadingConstraint { + embedConstraints.append(leadingConstraint) + } + + // - vertical position + insets + embedConstraints.append(contentsOf: [ + view.centerYAnchor.constraint(equalTo: centerYAnchor), + view.topAnchor.constraint(greaterThanOrEqualTo: topAnchor, constant: insets.top), + view.bottomAnchor.constraint(lessThanOrEqualTo: bottomAnchor, constant: -insets.bottom) + ]) + + // - trailing + if viewIdx == (views.count-1) { + let trailingConstraint = view.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -insets.trailing) + constraintSet.lastTrailingConstraint = trailingConstraint + embedConstraints.append(trailingConstraint) + } + + // Add subview + addSubview(view) + + previousView = view + viewIdx += 1 + } + + // Modify constraints + if let constraintsModifier = constraintsModifier { + constraintSet = constraintsModifier(constraintSet) + } + + // Activate constraints + NSLayoutConstraint.activate(embedConstraints) + + return constraintSet + } + + func embed(toFillWith view: UIView, insets: NSDirectionalEdgeInsets = .zero) { + view.translatesAutoresizingMaskIntoConstraints = false + + addSubview(view) + + NSLayoutConstraint.activate([ + view.leadingAnchor.constraint(equalTo: leadingAnchor, constant: insets.leading), + view.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -insets.trailing), + view.topAnchor.constraint(equalTo: topAnchor, constant: insets.top), + view.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -insets.bottom) + ]) + } +} diff --git a/ownCloudAppShared/User Interface/Theme/NSObject+ThemeApplication.swift b/ownCloudAppShared/User Interface/Theme/NSObject+ThemeApplication.swift index 493c4541d..b3adf4ec3 100644 --- a/ownCloudAppShared/User Interface/Theme/NSObject+ThemeApplication.swift +++ b/ownCloudAppShared/User Interface/Theme/NSObject+ThemeApplication.swift @@ -41,6 +41,9 @@ public enum ThemeItemStyle { case bigTitle case bigMessage + case system(textStyle: UIFont.TextStyle, weight: UIFont.Weight? = nil) + case systemSecondary(textStyle: UIFont.TextStyle, weight: UIFont.Weight? = nil) + case purchase case welcome } @@ -182,7 +185,7 @@ public extension NSObject { let disabledColor : UIColor = collection.tableRowColors.secondaryLabelColor switch itemStyle { - case .title, .bigTitle: + case .title, .bigTitle, .system(textStyle: _, weight: _): normalColor = collection.tableRowColors.labelColor highlightColor = collection.tableRowHighlightColors.labelColor @@ -194,7 +197,7 @@ public extension NSObject { normalColor = collection.loginColors.secondaryLabelColor highlightColor = collection.loginColors.secondaryLabelColor - case .message, .bigMessage: + case .message, .bigMessage, .systemSecondary(textStyle: _, weight: _): normalColor = collection.tableRowColors.secondaryLabelColor highlightColor = collection.tableRowHighlightColors.secondaryLabelColor @@ -214,6 +217,20 @@ public extension NSObject { case .bigMessage: label.font = UIFont.systemFont(ofSize: 17) + case .system(let txtStyle, let txtWeight): + if let txtWeight = txtWeight { + label.font = UIFont.preferredFont(forTextStyle: txtStyle, with: txtWeight) + } else { + label.font = UIFont.preferredFont(forTextStyle: txtStyle) + } + + case .systemSecondary(let txtStyle, let txtWeight): + if let txtWeight = txtWeight { + label.font = UIFont.preferredFont(forTextStyle: txtStyle, with: txtWeight) + } else { + label.font = UIFont.preferredFont(forTextStyle: txtStyle) + } + default: break } diff --git a/ownCloudAppShared/User Interface/Theme/TVG/TVGImage.swift b/ownCloudAppShared/User Interface/Theme/TVG/TVGImage.swift index d760f9b4e..eec75c83b 100644 --- a/ownCloudAppShared/User Interface/Theme/TVG/TVGImage.swift +++ b/ownCloudAppShared/User Interface/Theme/TVG/TVGImage.swift @@ -160,7 +160,7 @@ public class TVGImage: NSObject { var image : UIImage? if (fitInSize.width <= 0) || (fitInSize.height <= 0) { - Log.error("Image can't be rendered at size \(fitInSize)") + Log.debug("Image can't be rendered at size \(fitInSize)") return nil } diff --git a/ownCloudAppShared/User Interface/Theme/ThemeCollection.swift b/ownCloudAppShared/User Interface/Theme/ThemeCollection.swift index 693d4d829..2a38a9d42 100644 --- a/ownCloudAppShared/User Interface/Theme/ThemeCollection.swift +++ b/ownCloudAppShared/User Interface/Theme/ThemeCollection.swift @@ -116,6 +116,8 @@ public class ThemeCollection : NSObject { @objc public var purchaseColors : ThemeColorPairCollection + @objc public var tokenColors: ThemeColorPairCollection + // MARK: - Label colors @objc public var informativeColor: UIColor @objc public var successColor: UIColor @@ -132,6 +134,7 @@ public class ThemeCollection : NSObject { @objc public var tableSeparatorColor : UIColor? @objc public var tableRowColors : ThemeColorCollection @objc public var tableRowHighlightColors : ThemeColorCollection + @objc public var tableRowButtonColors : ThemeColorCollection @objc public var tableRowBorderColor : UIColor? // MARK: - Bars @@ -223,6 +226,8 @@ public class ThemeCollection : NSObject { self.purchaseColors.disabled.background = self.purchaseColors.disabled.background.greyscale self.destructiveColors = colors.resolveThemeColorPairCollection("Fill.destructiveColors", ThemeColorPairCollection(fromPair: ThemeColorPair(foreground: UIColor.white, background: UIColor.red))) + self.tokenColors = colors.resolveThemeColorPairCollection("Fill.tokenColors", ThemeColorPairCollection(fromPair: ThemeColorPair(foreground: lightBrandColor, background: UIColor(white: 0, alpha: 0.1)))) + self.tintColor = colors.resolveColor("tintColor", self.lightBrandColor) // Table view @@ -260,6 +265,15 @@ public class ThemeCollection : NSObject { filledColorPairCollection: ThemeColorPairCollection(fromPair: ThemeColorPair(foreground: UIColor.white, background: lightBrandColor)) )) + self.tableRowButtonColors = colors.resolveThemeColorCollection("Table.tableRowButtonColors", ThemeColorCollection( + backgroundColor: tableGroupBackgroundColor, + tintColor: nil, + labelColor: defaultTableRowLabelColor, + secondaryLabelColor: UIColor(hex: 0x475770), + symbolColor: UIColor(hex: 0x475770), + filledColorPairCollection: ThemeColorPairCollection(fromPair: ThemeColorPair(foreground: defaultTableRowLabelColor, background: tableGroupBackgroundColor)) + )) + self.favoriteEnabledColor = UIColor(hex: 0xFFCC00) self.favoriteDisabledColor = UIColor(hex: 0x7C7C7C) @@ -277,6 +291,8 @@ public class ThemeCollection : NSObject { self.searchBarColors = colors.resolveThemeColorCollection("Searchbar", self.darkBrandColors) self.loginColors = colors.resolveThemeColorCollection("Login", self.darkBrandColors) + self.tokenColors = colors.resolveThemeColorPairCollection("Fill.tokenColors", ThemeColorPairCollection(fromPair: ThemeColorPair(foreground: lightBrandColor, background: UIColor(white: 1, alpha: 0.1)))) + // Table view self.tableBackgroundColor = colors.resolveColor("Table.tableBackgroundColor", navigationBarColors.backgroundColor!.darker(0.1)) self.tableGroupBackgroundColor = colors.resolveColor("Table.tableGroupBackgroundColor", navigationBarColors.backgroundColor!.darker(0.3)) @@ -302,6 +318,15 @@ public class ThemeCollection : NSObject { filledColorPairCollection: ThemeColorPairCollection(fromPair: ThemeColorPair(foreground: UIColor.white, background: lightBrandColor)) )) + self.tableRowButtonColors = colors.resolveThemeColorCollection("Table.tableRowButtonColors", ThemeColorCollection( + backgroundColor: tableGroupBackgroundColor, + tintColor: navigationBarColors.tintColor, + labelColor: navigationBarColors.labelColor, + secondaryLabelColor: navigationBarColors.secondaryLabelColor, + symbolColor: lightColor, + filledColorPairCollection: ThemeColorPairCollection(fromPair: ThemeColorPair(foreground: lightColor, background: tableGroupBackgroundColor)) + )) + // Bar styles self.statusBarStyle = styleResolver.resolveStatusBarStyle(for: "statusBarStyle", fallback: .lightContent) self.loginStatusBarStyle = styleResolver.resolveStatusBarStyle(for: "loginStatusBarStyle", fallback: self.statusBarStyle) @@ -363,10 +388,10 @@ public class ThemeCollection : NSObject { // Bars self.navigationBarColors = colors.resolveThemeColorCollection("NavigationBar", self.darkBrandColors) let tmpDarkBrandColors = self.darkBrandColors - - if VendorServices.shared.isBranded { - tmpDarkBrandColors.secondaryLabelColor = UIColor(hex: 0xF7F7F7) - } + + if VendorServices.shared.isBranded { + tmpDarkBrandColors.secondaryLabelColor = UIColor(hex: 0xF7F7F7) + } if self.tintColor == UIColor(hex: 0xFFFFFF) { tmpDarkBrandColors.secondaryLabelColor = .lightGray } diff --git a/ownCloudAppShared/User Interface/Theme/UI/ThemeView.swift b/ownCloudAppShared/User Interface/Theme/UI/ThemeView.swift index cb07a330b..344375cc6 100644 --- a/ownCloudAppShared/User Interface/Theme/UI/ThemeView.swift +++ b/ownCloudAppShared/User Interface/Theme/UI/ThemeView.swift @@ -38,6 +38,8 @@ open class ThemeView: UIView, Themeable { override open func didMoveToSuperview() { if self.superview != nil { if !hasRegistered { + setupSubviews() + hasRegistered = true Theme.shared.register(client: self, applyImmediately: true) } @@ -46,6 +48,10 @@ open class ThemeView: UIView, Themeable { private var themeAppliers : [ThemeApplier] = [] + open func setupSubviews() { + // Override point for subclasses + } + open func addThemeApplier(_ applier: @escaping ThemeApplier) { themeAppliers.append(applier) }