diff --git a/WordPress.xcworkspace/xcshareddata/swiftpm/Package.resolved b/WordPress.xcworkspace/xcshareddata/swiftpm/Package.resolved index 420a9c1c1408..d4b7d8a6ab5f 100644 --- a/WordPress.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/WordPress.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -392,7 +392,7 @@ "location" : "https://github.com/wordpress-mobile/WordPressKit-iOS", "state" : { "branch" : "wpios-edition", - "revision" : "554d3ef682af4cfd7888e33658789df48b67c408" + "revision" : "7dc4ed37223be387e95a5d68e23f1dc956db318b" } }, { diff --git a/WordPress/Classes/Services/ThemeService.h b/WordPress/Classes/Services/ThemeService.h index bb004f0867b9..9e44b48aa638 100644 --- a/WordPress/Classes/Services/ThemeService.h +++ b/WordPress/Classes/Services/ThemeService.h @@ -48,6 +48,7 @@ typedef void(^ThemeServiceFailureBlock)(NSError *error); * * @param blogId The blog to get the themes for. Cannot be nil. * @param page Results page to return. + * @param search Search string to filter themes. * @param sync Whether to remove unsynced results. * @param success The success handler. Can be nil. * @param failure The failure handler. Can be nil. @@ -56,6 +57,7 @@ typedef void(^ThemeServiceFailureBlock)(NSError *error); */ - (NSProgress *)getThemesForBlog:(Blog *)blog page:(NSInteger)page + search:(NSString *)search sync:(BOOL)sync success:(ThemeServiceThemesRequestSuccessBlock)success failure:(ThemeServiceFailureBlock)failure; diff --git a/WordPress/Classes/Services/ThemeService.m b/WordPress/Classes/Services/ThemeService.m index 8f4908299743..5613a0f3c163 100644 --- a/WordPress/Classes/Services/ThemeService.m +++ b/WordPress/Classes/Services/ThemeService.m @@ -149,6 +149,7 @@ - (NSProgress *)getActiveThemeForBlog:(Blog *)blog - (NSProgress *)getThemesForBlog:(Blog *)blog page:(NSInteger)page + search:(NSString *)search sync:(BOOL)sync success:(ThemeServiceThemesRequestSuccessBlock)success failure:(ThemeServiceFailureBlock)failure @@ -157,6 +158,10 @@ - (NSProgress *)getThemesForBlog:(Blog *)blog NSAssert([self blogSupportsThemeServices:blog], @"Do not call this method on unsupported blogs, check with blogSupportsThemeServices first."); + if (search.length == 0) { + search = nil; + } + if (blog.wordPressComRestApi == nil) { return nil; } @@ -165,6 +170,7 @@ - (NSProgress *)getThemesForBlog:(Blog *)blog if ([blog supports:BlogFeatureCustomThemes]) { return [remote getWPThemesPage:page + search:search freeOnly:![blog supports:BlogFeaturePremiumThemes] success:^(NSArray *remoteThemes, BOOL hasMore, NSInteger totalThemeCount) { NSArray * __block themeObjectIDs = nil; diff --git a/WordPress/Classes/ViewRelated/Themes/ThemeBrowserViewController.swift b/WordPress/Classes/ViewRelated/Themes/ThemeBrowserViewController.swift index 4853cb5e2133..6c1b64163a49 100644 --- a/WordPress/Classes/ViewRelated/Themes/ThemeBrowserViewController.swift +++ b/WordPress/Classes/ViewRelated/Themes/ThemeBrowserViewController.swift @@ -452,11 +452,12 @@ public protocol ThemePresenter: AnyObject { } } - fileprivate func syncThemePage(_ page: NSInteger, success: ((_ hasMore: Bool) -> Void)?, failure: ((_ error: NSError) -> Void)?) { + private func syncThemePage(_ page: NSInteger, search: String, success: ((_ hasMore: Bool) -> Void)?, failure: ((_ error: NSError) -> Void)?) { assert(page > 0) themesSyncingPage = page _ = themeService.getThemesFor(blog, page: themesSyncingPage, + search: search, sync: page == 1, success: {[weak self](themes: [Theme]?, hasMore: Bool, themeCount: NSInteger) in if let success { @@ -509,7 +510,7 @@ public protocol ThemePresenter: AnyObject { func syncHelper(_ syncHelper: WPContentSyncHelper, syncContentWithUserInteraction userInteraction: Bool, success: ((_ hasMore: Bool) -> Void)?, failure: ((_ error: NSError) -> Void)?) { if syncHelper == themesSyncHelper { - syncThemePage(1, success: success, failure: failure) + syncThemePage(1, search: searchName, success: success, failure: failure) } else if syncHelper == customThemesSyncHelper { syncCustomThemes(success: success, failure: failure) } @@ -518,7 +519,7 @@ public protocol ThemePresenter: AnyObject { func syncHelper(_ syncHelper: WPContentSyncHelper, syncMoreWithSuccess success: ((_ hasMore: Bool) -> Void)?, failure: ((_ error: NSError) -> Void)?) { if syncHelper == themesSyncHelper { let nextPage = themesSyncingPage + 1 - syncThemePage(nextPage, success: success, failure: failure) + syncThemePage(nextPage, search: searchName, success: success, failure: failure) } } @@ -657,11 +658,65 @@ public protocol ThemePresenter: AnyObject { // MARK: - Search support + private var searchDebounceTimer: Timer? + private let searchDebounceInterval: TimeInterval = 0.5 + + private func resetRemoteSearch() { + themesSyncingPage = 0 + + if blog.supports(BlogFeature.customThemes) { + themesSyncHelper.syncContent() + } + } + fileprivate func beginSearchFor(_ pattern: String) { searchController.isActive = true searchController.searchBar.text = pattern - searchName = pattern + updateSearchName(pattern) + } + + private func updateSearchName(_ searchText: String) { + // Cancel any existing timer + searchDebounceTimer?.invalidate() + + // If search text is empty, update immediately and reset remote search + if searchText.isEmpty { + self.searchName = searchText + self.fetchThemes() + self.resetRemoteSearch() + self.reloadThemes() + return + } + + // Check if we have a previously longer search that is now under 3 characters + let previouslyHadRemoteSearch = self.searchName.count >= 3 + + // Create a new timer for debounce + searchDebounceTimer = Timer.scheduledTimer(withTimeInterval: searchDebounceInterval, repeats: false) { [weak self] _ in + guard let self else { return } + self.searchName = searchText + + // Apply local search immediately + self.fetchThemes() + + // Remote search only applies to WordPress.com themes and only if customThemes are supported. + // The remote endpoint support search just for 3+ characters + if self.blog.supports(BlogFeature.customThemes) { + if searchText.count >= 3 { + // Reset to first page when searching + self.themesSyncingPage = 0 + self.themesSyncHelper.syncContent() + } else if previouslyHadRemoteSearch { + // If we previously had 3+ characters but now have less, + // we need to reset the remote search results + self.resetRemoteSearch() + } + } + + // Always reload with local results + self.reloadThemes() + } } // MARK: - UISearchControllerDelegate @@ -682,6 +737,7 @@ public protocol ThemePresenter: AnyObject { hideSectionHeaders = false searchName = "" searchController.searchBar.text = "" + resetRemoteSearch() } open func didDismissSearchController(_ searchController: UISearchController) { @@ -709,31 +765,44 @@ public protocol ThemePresenter: AnyObject { // MARK: - UISearchResultsUpdating open func updateSearchResults(for searchController: UISearchController) { - searchName = searchController.searchBar.text ?? "" + updateSearchName(searchController.searchBar.text ?? "") } // MARK: - NSFetchedResultsController helpers - fileprivate func searchNamePredicate() -> NSPredicate? { - guard !searchName.isEmpty else { - return nil - } - - return NSPredicate(format: "name contains[c] %@", searchName) - } - fileprivate func browsePredicate() -> NSPredicate? { return browsePredicateThemesWithCustomValue(false) } fileprivate func customThemesBrowsePredicate() -> NSPredicate? { - return browsePredicateThemesWithCustomValue(true) + let browsePredicate = browsePredicateThemesWithCustomValue(true) + + // Search predicate for custom themes (local search only) + if !searchName.isEmpty { + let searchPredicate = NSPredicate(format: "name CONTAINS[cd] %@", searchName) + if let existingPredicate = browsePredicate { + return NSCompoundPredicate(andPredicateWithSubpredicates: [existingPredicate, searchPredicate]) + } else { + return searchPredicate + } + } + + return browsePredicate } fileprivate func browsePredicateThemesWithCustomValue(_ custom: Bool) -> NSPredicate? { let blogPredicate = NSPredicate(format: "blog == %@ AND custom == %d", self.blog, custom ? 1 : 0) - let subpredicates = [blogPredicate, searchNamePredicate(), filterType.predicate].compactMap { $0 } + let subpredicates = [blogPredicate, filterType.predicate].compactMap { $0 } + + // For regular themes, add local search predicate if: + // 1. Not using custom themes feature, or + // 2. Search term is less than 3 characters (we'll only search locally for short terms) + if !searchName.isEmpty && !custom && (!blog.supports(BlogFeature.customThemes) || searchName.count < 3) { + let searchPredicate = NSPredicate(format: "name CONTAINS[cd] %@", searchName) + return NSCompoundPredicate(andPredicateWithSubpredicates: subpredicates + [searchPredicate]) + } + switch subpredicates.count { case 1: return subpredicates[0] diff --git a/WordPress/WordPressTest/ThemeServiceTests.m b/WordPress/WordPressTest/ThemeServiceTests.m index 449653b6baf8..2d8ed1ab928c 100644 --- a/WordPress/WordPressTest/ThemeServiceTests.m +++ b/WordPress/WordPressTest/ThemeServiceTests.m @@ -112,6 +112,7 @@ - (void)testThatGetThemesForBlogWorks XCTAssertNoThrow(service = [[ThemeService alloc] initWithCoreDataStack:self.manager]); XCTAssertNoThrow([service getThemesForBlog:blog page:1 + search:nil sync:NO success:nil failure:nil]); @@ -124,6 +125,7 @@ - (void)testThatGetThemesForBlogThrowsExceptionWithoutBlog XCTAssertNoThrow(service = [[ThemeService alloc] initWithCoreDataStack:self.manager]); XCTAssertThrows([service getThemesForBlog:nil page:1 + search:nil sync:NO success:nil failure:nil]);