From cf7b28b691f631c19406569dfcdbb0e4ea5618e7 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Tue, 5 Aug 2025 11:29:44 -0400 Subject: [PATCH] Integrate JetpackStats in the app --- .../Misc/StatsPeriodAsyncOperationTests.swift | 15 +- .../WPAnalyticsEvent+JetpackStats.swift | 65 +++++ .../Utility/Analytics/WPAnalyticsEvent.swift | 102 ++++++++ .../BuildInformation/FeatureFlag.swift | 4 + .../Classes/Utility/ContentCoordinator.swift | 5 +- .../DashboardQuickActionsCardCell.swift | 12 +- .../BlogDetailsViewController+Swift.swift | 15 +- .../Likes/LikesListController.swift | 12 + .../ExperimentalFeaturesDataProvider.swift | 1 + .../NotificationContentRouter.swift | 5 +- .../AbstractPostListViewController.swift | 32 ++- .../Stats/Charts/Charts+Support.swift | 7 + .../Stats/Helpers/StatsDataHelper.swift | 6 + .../Stats/Helpers/StatsPeriodHelper.swift | 14 ++ .../Stats/PostStatsViewController.swift | 67 +++++ .../SiteStatsTableHeaderView.swift | 3 + .../PostStatsTableViewController.swift | 4 + .../SiteStatsDashboardViewController.swift | 234 +++++++++++++++++- .../Stats/StatsHostingViewController.swift | 150 +++++++++++ .../Stats/StatsLikesListViewController.swift | 105 ++++++++ .../ViewRelated/Stats/StatsViewController.h | 2 - .../ViewRelated/Stats/StatsViewController.m | 9 - .../Stats/StatsViewController.swift | 1 - .../Traffic/StatsTrafficDatePickerView.swift | 2 + .../StatsTrafficDatePickerViewModel.swift | 6 + .../ViewRelated/System/FilterTabBar.swift | 22 ++ .../ViewRelated/System/WPTabBarController.m | 2 +- .../Classes/ViewRelated/Tips/AppTips.swift | 29 +++ 28 files changed, 882 insertions(+), 49 deletions(-) create mode 100644 WordPress/Classes/Utility/Analytics/WPAnalyticsEvent+JetpackStats.swift create mode 100644 WordPress/Classes/ViewRelated/Stats/PostStatsViewController.swift create mode 100644 WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift create mode 100644 WordPress/Classes/ViewRelated/Stats/StatsLikesListViewController.swift diff --git a/Tests/KeystoneTests/Tests/Misc/StatsPeriodAsyncOperationTests.swift b/Tests/KeystoneTests/Tests/Misc/StatsPeriodAsyncOperationTests.swift index 3cc4c477a5ff..5849f9d05bdf 100644 --- a/Tests/KeystoneTests/Tests/Misc/StatsPeriodAsyncOperationTests.swift +++ b/Tests/KeystoneTests/Tests/Misc/StatsPeriodAsyncOperationTests.swift @@ -28,11 +28,16 @@ class StatsPeriodAsyncOperationTests: XCTestCase { private extension StatsPeriodAsyncOperationTests { class MockStatsServiceRemoteV2: StatsServiceRemoteV2 { - override func getData(for period: StatsPeriodUnit, - unit: StatsPeriodUnit?, - endingOn: Date, - limit: Int = 10, - completion: @escaping ((TimeStatsType?, Error?) -> Void)) { + override func getData( + for period: StatsPeriodUnit, + unit: StatsPeriodUnit? = nil, + startDate: Date? = nil, + endingOn: Date, + limit: Int = 10, + summarize: Bool? = nil, + parameters: [String: String]? = nil, + completion: @escaping (TimeStatsType?, (any Error)?) -> Void + ) where TimeStatsType: StatsTimeIntervalData { let mockType = TimeStatsType(date: endingOn, period: period, unit: unit, diff --git a/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent+JetpackStats.swift b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent+JetpackStats.swift new file mode 100644 index 000000000000..4e56015340d5 --- /dev/null +++ b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent+JetpackStats.swift @@ -0,0 +1,65 @@ +import Foundation +import JetpackStats + +extension StatsEvent { + /// Maps JetpackStats events to WordPress analytics events + var wpEvent: WPAnalyticsEvent { + switch self { + // Screen View Events + case .statsMainScreenShown: + return .jetpackStatsMainScreenShown + case .trafficTabShown: + return .jetpackStatsTrafficTabShown + case .realtimeTabShown: + return .jetpackStatsRealtimeTabShown + case .subscribersTabShown: + return .jetpackStatsSubscribersTabShown + case .postDetailsScreenShown: + return .jetpackStatsPostDetailsScreenShown + case .authorStatsScreenShown: + return .jetpackStatsAuthorStatsScreenShown + case .archiveStatsScreenShown: + return .jetpackStatsArchiveStatsScreenShown + case .externalLinkStatsScreenShown: + return .jetpackStatsExternalLinkStatsScreenShown + case .referrerStatsScreenShown: + return .jetpackStatsReferrerStatsScreenShown + + // Date Range Events + case .dateRangePresetSelected: + return .jetpackStatsDateRangePresetSelected + case .customDateRangeSelected: + return .jetpackStatsCustomDateRangeSelected + + // Card Events + case .cardShown: + return .jetpackStatsCardShown + case .cardAdded: + return .jetpackStatsCardAdded + case .cardRemoved: + return .jetpackStatsCardRemoved + + // Chart Events + case .chartTypeChanged: + return .jetpackStatsChartTypeChanged + case .chartMetricSelected: + return .jetpackStatsChartMetricSelected + + // List Events + case .topListItemTapped: + return .jetpackStatsTopListItemTapped + + // Navigation Events + case .statsTabSelected: + return .jetpackStatsTabSelected + + // Error Events + case .errorEncountered: + return .jetpackStatsErrorEncountered + } + } +} + +extension WPAnalyticsEvent { + static let isNewStatsKey = "new_stats" +} diff --git a/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift index 27f13b609af5..5cda1836bf10 100644 --- a/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift +++ b/WordPress/Classes/Utility/Analytics/WPAnalyticsEvent.swift @@ -609,6 +609,10 @@ import WordPressShared case statsEmailsViewMoreTapped case statsSubscribersChartTapped + // New Stats + case statsNewStatsEnabled + case statsNewStatsDisabled + // In-App Updates case inAppUpdateShown case inAppUpdateDismissed @@ -619,6 +623,42 @@ import WordPressShared case wpcomWebSignIn + // MARK: - Jetpack Stats + + // Screen View Events + case jetpackStatsMainScreenShown + case jetpackStatsTrafficTabShown + case jetpackStatsRealtimeTabShown + case jetpackStatsSubscribersTabShown + case jetpackStatsPostDetailsScreenShown + case jetpackStatsAuthorStatsScreenShown + case jetpackStatsArchiveStatsScreenShown + case jetpackStatsExternalLinkStatsScreenShown + case jetpackStatsReferrerStatsScreenShown + + // Date Range Events + case jetpackStatsDateRangePresetSelected + case jetpackStatsCustomDateRangeSelected + + // Card Events + case jetpackStatsCardShown + case jetpackStatsCardAdded + case jetpackStatsCardRemoved + case jetpackStatsCardEditMenuOpened + + // Chart Events + case jetpackStatsChartTypeChanged + case jetpackStatsChartMetricSelected + + // List Events + case jetpackStatsTopListItemTapped + + // Navigation Events + case jetpackStatsTabSelected + + // Error Events + case jetpackStatsErrorEncountered + /// A String that represents the event var value: String { switch self { @@ -1664,6 +1704,12 @@ import WordPressShared case .statsSubscribersChartTapped: return "stats_subscribers_chart_tapped" + // New Stats + case .statsNewStatsEnabled: + return "stats_new_stats_enabled" + case .statsNewStatsDisabled: + return "stats_new_stats_disabled" + // In-App Updates case .inAppUpdateShown: return "in_app_update_shown" @@ -1678,6 +1724,62 @@ import WordPressShared case .wpcomWebSignIn: return "wpcom_web_sign_in" + + // MARK: - Jetpack Stats + + // Screen View Events + case .jetpackStatsMainScreenShown: + return "jetpack_stats_main_screen_shown" + case .jetpackStatsTrafficTabShown: + return "jetpack_stats_traffic_tab_shown" + case .jetpackStatsRealtimeTabShown: + return "jetpack_stats_realtime_tab_shown" + case .jetpackStatsSubscribersTabShown: + return "jetpack_stats_subscribers_tab_shown" + case .jetpackStatsPostDetailsScreenShown: + return "jetpack_stats_post_details_screen_shown" + case .jetpackStatsAuthorStatsScreenShown: + return "jetpack_stats_author_stats_screen_shown" + case .jetpackStatsArchiveStatsScreenShown: + return "jetpack_stats_archive_stats_screen_shown" + case .jetpackStatsExternalLinkStatsScreenShown: + return "jetpack_stats_external_link_stats_screen_shown" + case .jetpackStatsReferrerStatsScreenShown: + return "jetpack_stats_referrer_stats_screen_shown" + + // Date Range Events + case .jetpackStatsDateRangePresetSelected: + return "jetpack_stats_date_range_preset_selected" + case .jetpackStatsCustomDateRangeSelected: + return "jetpack_stats_custom_date_range_selected" + + // Card Events + case .jetpackStatsCardShown: + return "jetpack_stats_card_shown" + case .jetpackStatsCardAdded: + return "jetpack_stats_card_added" + case .jetpackStatsCardRemoved: + return "jetpack_stats_card_removed" + case .jetpackStatsCardEditMenuOpened: + return "jetpack_stats_card_edit_menu_opened" + + // Chart Events + case .jetpackStatsChartTypeChanged: + return "jetpack_stats_chart_type_changed" + case .jetpackStatsChartMetricSelected: + return "jetpack_stats_chart_metric_selected" + + // List Events + case .jetpackStatsTopListItemTapped: + return "jetpack_stats_top_list_item_tapped" + + // Navigation Events + case .jetpackStatsTabSelected: + return "jetpack_stats_tab_selected" + + // Error Events + case .jetpackStatsErrorEncountered: + return "jetpack_stats_error_encountered" } // END OF SWITCH } diff --git a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift index b0621431d471..d10a954ffc65 100644 --- a/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift +++ b/WordPress/Classes/Utility/BuildInformation/FeatureFlag.swift @@ -26,6 +26,7 @@ public enum FeatureFlag: Int, CaseIterable { case pluginManagementOverhaul case nativeJetpackConnection case newsletterSubscribers + case newStats /// Returns a boolean indicating if the feature is enabled. /// @@ -82,6 +83,8 @@ public enum FeatureFlag: Int, CaseIterable { return BuildConfiguration.current == .debug case .newsletterSubscribers: return true + case .newStats: + return false } } @@ -125,6 +128,7 @@ extension FeatureFlag { case .readerGutenbergCommentComposer: "Gutenberg Comment Composer" case .nativeJetpackConnection: "Native Jetpack Connection" case .newsletterSubscribers: "Newsletter Subscribers" + case .newStats: "New Stats" } } } diff --git a/WordPress/Classes/Utility/ContentCoordinator.swift b/WordPress/Classes/Utility/ContentCoordinator.swift index 9ad8bd8145bf..31c6e0c7a317 100644 --- a/WordPress/Classes/Utility/ContentCoordinator.swift +++ b/WordPress/Classes/Utility/ContentCoordinator.swift @@ -65,9 +65,8 @@ struct DefaultContentCoordinator: ContentCoordinator { setTimePeriodForStatsURLIfPossible(url) } - let statsViewController = StatsViewController() - statsViewController.blog = blog - controller?.navigationController?.pushViewController(statsViewController, animated: true) + let statsVC = StatsHostingViewController.makeStatsViewController(for: blog) + controller?.navigationController?.pushViewController(statsVC, animated: true) } private func setTimePeriodForStatsURLIfPossible(_ url: URL) { diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsCardCell.swift b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsCardCell.swift index ecf1c021e113..402a91fa9627 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsCardCell.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Dashboard/Cards/Quick Actions/DashboardQuickActionsCardCell.swift @@ -108,7 +108,8 @@ final class DashboardQuickActionsCardCell: UICollectionViewCell, Reusable, UITab parentViewController.show(controller, sender: nil) case .stats: trackQuickActionsEvent(.statsAccessed, blog: blog) - StatsViewController.show(for: blog, from: parentViewController) + let statsVC = StatsHostingViewController.makeStatsViewController(for: blog) + parentViewController.show(statsVC, sender: nil) case .more: let viewController = BlogDetailsViewController() viewController.isScrollEnabled = true @@ -121,7 +122,14 @@ final class DashboardQuickActionsCardCell: UICollectionViewCell, Reusable, UITab } private func trackQuickActionsEvent(_ event: WPAnalyticsStat, blog: Blog) { - WPAppAnalytics.track(event, properties: [WPAppAnalyticsKeyTabSource: "dashboard", WPAppAnalyticsKeyTapSource: "quick_actions"], blog: blog) + var properties: [String: Any] = [ + WPAppAnalyticsKeyTabSource: "dashboard", + WPAppAnalyticsKeyTapSource: "quick_actions" + ] + if event == .statsAccessed, FeatureFlag.newStats.enabled { + properties[WPAnalyticsEvent.isNewStatsKey] = "1" + } + WPAppAnalytics.track(event, properties: properties, blog: blog) } } diff --git a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift index 3a79067250c9..11ef0478e475 100644 --- a/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift +++ b/WordPress/Classes/ViewRelated/Blog/Blog Details/BlogDetailsViewController+Swift.swift @@ -265,12 +265,7 @@ extension BlogDetailsViewController { guard JetpackFeaturesRemovalCoordinator.jetpackFeaturesEnabled() else { return MovedToJetpackViewController(source: .stats) } - - let statsVC = StatsViewController() - statsVC.blog = blog - statsVC.hidesBottomBarWhenPushed = true - statsVC.navigationItem.largeTitleDisplayMode = .never - return statsVC + return StatsHostingViewController.makeStatsViewController(for: blog) } @objc(showDomainsFromSource:) @@ -423,10 +418,14 @@ extension BlogDetailsViewController { extension BlogDetailsViewController { @objc public func trackEvent(_ event: WPAnalyticsStat, from source: BlogDetailsNavigationSource) { - WPAppAnalytics.track(event, properties: [ + var properties: [String: Any] = [ WPAppAnalyticsKeyTapSource: source.string, WPAppAnalyticsKeyTabSource: "site_menu" - ], blog: blog) + ] + if event == .statsAccessed, FeatureFlag.newStats.enabled { + properties[WPAnalyticsEvent.isNewStatsKey] = "1" + } + WPAppAnalytics.track(event, properties: properties, blog: blog) } } diff --git a/WordPress/Classes/ViewRelated/Likes/LikesListController.swift b/WordPress/Classes/ViewRelated/Likes/LikesListController.swift index e47a74023d4e..e9546e606ec7 100644 --- a/WordPress/Classes/ViewRelated/Likes/LikesListController.swift +++ b/WordPress/Classes/ViewRelated/Likes/LikesListController.swift @@ -143,6 +143,18 @@ class LikesListController: NSObject { configureLoadingIndicator() } + /// Init with siteID and postID + /// + init(tableView: UITableView, siteID: NSNumber, postID: NSNumber, delegate: LikesListControllerDelegate? = nil) { + content = .post(id: postID) + self.siteID = siteID + self.tableView = tableView + self.delegate = delegate + + super.init() + configureLoadingIndicator() + } + private func configureLoadingIndicator() { loadingIndicator = UIActivityIndicatorView(style: .medium) loadingIndicator.frame = CGRect(x: 0, y: 0, width: tableView.bounds.width, height: 44) diff --git a/WordPress/Classes/ViewRelated/Me/App Settings/ExperimentalFeaturesDataProvider.swift b/WordPress/Classes/ViewRelated/Me/App Settings/ExperimentalFeaturesDataProvider.swift index bb22f5b4509f..1d8bfaaa573d 100644 --- a/WordPress/Classes/ViewRelated/Me/App Settings/ExperimentalFeaturesDataProvider.swift +++ b/WordPress/Classes/ViewRelated/Me/App Settings/ExperimentalFeaturesDataProvider.swift @@ -9,6 +9,7 @@ class ExperimentalFeaturesDataProvider: ExperimentalFeaturesViewModel.DataProvid FeatureFlag.allowApplicationPasswords, RemoteFeatureFlag.newGutenberg, FeatureFlag.newGutenbergThemeStyles, + FeatureFlag.newStats, ] private let flagStore = FeatureFlagOverrideStore() diff --git a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationContentRouter.swift b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationContentRouter.swift index 615a536bcdab..b6749ff15cdb 100644 --- a/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationContentRouter.swift +++ b/WordPress/Classes/ViewRelated/Notifications/Controllers/NotificationContentRouter.swift @@ -116,7 +116,10 @@ struct NotificationContentRouter { } private func trackStatsRoute() { - let properties: [AnyHashable: Any] = [WPAppAnalyticsKeyTapSource: "notification"] + var properties: [AnyHashable: Any] = [WPAppAnalyticsKeyTapSource: "notification"] + if FeatureFlag.newStats.enabled { + properties[WPAnalyticsEvent.isNewStatsKey] = "1" + } WPAppAnalytics.track(.statsAccessed, withProperties: properties) } } diff --git a/WordPress/Classes/ViewRelated/Post/Controllers/AbstractPostListViewController.swift b/WordPress/Classes/ViewRelated/Post/Controllers/AbstractPostListViewController.swift index bde8ccd2c641..8a91716eb9d7 100644 --- a/WordPress/Classes/ViewRelated/Post/Controllers/AbstractPostListViewController.swift +++ b/WordPress/Classes/ViewRelated/Post/Controllers/AbstractPostListViewController.swift @@ -7,6 +7,8 @@ import WordPressFlux import WordPressUI import WordPressKit import Combine +import SwiftUI +import JetpackStats class AbstractPostListViewController: UIViewController, WPContentSyncHelperDelegate, @@ -740,17 +742,29 @@ class AbstractPostListViewController: UIViewController, return } - SiteStatsInformation.sharedInstance.siteTimeZone = blog.timeZone - SiteStatsInformation.sharedInstance.oauth2Token = blog.authToken - SiteStatsInformation.sharedInstance.siteID = blog.dotComID + if FeatureFlag.newStats.enabled { + // Create the view controller + let statsViewController = PostStatsViewController(post: post) - guard let postURL = post.permaLink.flatMap(URL.init) else { - return wpAssertionFailure("permalink missing or invalid") + // Present modally in a navigation controller + let navController = UINavigationController(rootViewController: statsViewController) + navController.modalPresentationStyle = .pageSheet + + present(navController, animated: true) + } else { + // Use legacy stats view + SiteStatsInformation.sharedInstance.siteTimeZone = blog.timeZone + SiteStatsInformation.sharedInstance.oauth2Token = blog.authToken + SiteStatsInformation.sharedInstance.siteID = blog.dotComID + + guard let postURL = post.permaLink.flatMap(URL.init) else { + return wpAssertionFailure("permalink missing or invalid") + } + let postStatsTableViewController = PostStatsTableViewController.withJPBannerForBlog(postID: postID, + postTitle: post.titleForDisplay(), + postURL: postURL) + navigationController?.pushViewController(postStatsTableViewController, animated: true) } - let postStatsTableViewController = PostStatsTableViewController.withJPBannerForBlog(postID: postID, - postTitle: post.titleForDisplay(), - postURL: postURL) - navigationController?.pushViewController(postStatsTableViewController, animated: true) } @objc func copyPostLink(_ post: AbstractPost) { diff --git a/WordPress/Classes/ViewRelated/Stats/Charts/Charts+Support.swift b/WordPress/Classes/ViewRelated/Stats/Charts/Charts+Support.swift index 2b83b908098e..32447ee2f7c7 100644 --- a/WordPress/Classes/ViewRelated/Stats/Charts/Charts+Support.swift +++ b/WordPress/Classes/ViewRelated/Stats/Charts/Charts+Support.swift @@ -1,6 +1,7 @@ import UIKit import DGCharts import WordPressKit +import WordPressShared // MARK: - Charts extensions @@ -116,6 +117,9 @@ enum LineChartAnalyticsPropertyGranularityValue: String, CaseIterable { extension StatsPeriodUnit { var analyticsGranularity: BarChartAnalyticsPropertyGranularityValue { switch self { + case .hour: + wpAssertionFailure("StatsPeriodHelper.hour period is unsupported in the legacy stats") + return .days case .day: return .days case .week: @@ -129,6 +133,9 @@ extension StatsPeriodUnit { var analyticsGranularityLine: LineChartAnalyticsPropertyGranularityValue { switch self { + case .hour: + wpAssertionFailure("StatsPeriodHelper.hour period is unsupported in the legacy stats") + return .days case .day: return .days case .week: diff --git a/WordPress/Classes/ViewRelated/Stats/Helpers/StatsDataHelper.swift b/WordPress/Classes/ViewRelated/Stats/Helpers/StatsDataHelper.swift index 642ec97d11a1..748f671ce04b 100644 --- a/WordPress/Classes/ViewRelated/Stats/Helpers/StatsDataHelper.swift +++ b/WordPress/Classes/ViewRelated/Stats/Helpers/StatsDataHelper.swift @@ -247,6 +247,9 @@ extension StatsPeriodUnit { var dateFormatTemplate: String { switch self { + case .hour: + wpAssertionFailure("StatsPeriodHelper.hour period is unsupported in the legacy stats") + return "MMM d, yyyy" case .day: return "MMM d, yyyy" case .week: @@ -260,6 +263,8 @@ extension StatsPeriodUnit { var calendarComponent: Calendar.Component { switch self { + case .hour: + return .hour case .day: return .day case .week: @@ -273,6 +278,7 @@ extension StatsPeriodUnit { var description: String { switch self { + case .hour: return "hour" case .day: return "day" case .week: return "week" case .month: return "month" diff --git a/WordPress/Classes/ViewRelated/Stats/Helpers/StatsPeriodHelper.swift b/WordPress/Classes/ViewRelated/Stats/Helpers/StatsPeriodHelper.swift index 9dfac8976acd..29d523b285bd 100644 --- a/WordPress/Classes/ViewRelated/Stats/Helpers/StatsPeriodHelper.swift +++ b/WordPress/Classes/ViewRelated/Stats/Helpers/StatsPeriodHelper.swift @@ -1,5 +1,6 @@ import Foundation import WordPressKit +import WordPressShared class StatsPeriodHelper { private lazy var calendar: Calendar = { @@ -20,6 +21,9 @@ class StatsPeriodHelper { oldestDate = oldestDate.normalizedDate() switch period { + case .hour: + wpAssertionFailure("StatsPeriodHelper.hour period is unsupported in the legacy stats") + return date > oldestDate case .day: return date > oldestDate case .week: @@ -47,6 +51,9 @@ class StatsPeriodHelper { let date = dateIn.normalizedDate() switch period { + case .hour: + wpAssertionFailure("StatsPeriodHelper.hour period is unsupported in the legacy stats") + return date < currentDate.normalizedDate() case .day: return date < currentDate.normalizedDate() case .week: @@ -70,6 +77,9 @@ class StatsPeriodHelper { func endDate(from intervalStartDate: Date, period: StatsPeriodUnit) -> Date { switch period { + case .hour: + wpAssertionFailure("StatsPeriodHelper.hour period is unsupported in the legacy stats") + return intervalStartDate.normalizedDate() case .day: return intervalStartDate.normalizedDate() case .week: @@ -103,6 +113,10 @@ class StatsPeriodHelper { } switch unit { + case .hour: + wpAssertionFailure("StatsPeriodHelper.hour period is unsupported in the legacy stats") + return adjustedDate.normalizedDate() + case .day: return adjustedDate.normalizedDate() diff --git a/WordPress/Classes/ViewRelated/Stats/PostStatsViewController.swift b/WordPress/Classes/ViewRelated/Stats/PostStatsViewController.swift new file mode 100644 index 000000000000..52cf5de9954e --- /dev/null +++ b/WordPress/Classes/ViewRelated/Stats/PostStatsViewController.swift @@ -0,0 +1,67 @@ +import UIKit +import SwiftUI +import JetpackStats +import WordPressKit +import WordPressUI +import WordPressShared + +/// View controller that displays post statistics using the new SwiftUI PostStatsView +final class PostStatsViewController: UIViewController { + private let post: AbstractPost + + init(post: AbstractPost) { + self.post = post + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .systemBackground + + setupStatsView() + setupNavigationBar() + } + + private func setupStatsView() { + guard let context = StatsContext(blog: post.blog), + let postID = post.postID?.intValue else { + return + } + let info = PostStatsView.PostInfo( + title: post.titleForDisplay() ?? "", + postID: String(postID), + postURL: post.permaLink.flatMap(URL.init), + date: post.dateCreated + ) + let statsView = PostStatsView.make( + post: info, + context: context, + router: StatsRouter(viewController: self) + ) + let hostingController = UIHostingController(rootView: statsView) + + addChild(hostingController) + view.addSubview(hostingController.view) + hostingController.view.pinEdges() + hostingController.didMove(toParent: self) + } + + private func setupNavigationBar() { + if presentingViewController != nil { + navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .done, + target: self, + action: #selector(dismissViewController) + ) + } + } + + @objc private func dismissViewController() { + presentingViewController?.dismiss(animated: true) + } +} diff --git a/WordPress/Classes/ViewRelated/Stats/Shared Views/Date Chooser/SiteStatsTableHeaderView.swift b/WordPress/Classes/ViewRelated/Stats/Shared Views/Date Chooser/SiteStatsTableHeaderView.swift index 2472f7050162..e5ecd9694e8b 100644 --- a/WordPress/Classes/ViewRelated/Stats/Shared Views/Date Chooser/SiteStatsTableHeaderView.swift +++ b/WordPress/Classes/ViewRelated/Stats/Shared Views/Date Chooser/SiteStatsTableHeaderView.swift @@ -202,6 +202,9 @@ private extension SiteStatsTableHeaderView { dateFormatter.setLocalizedDateFormatFromTemplate(period.dateFormatTemplate) switch period { + case .hour: + wpAssertionFailure("StatsPeriodHelper.hour period is unsupported in the legacy stats") + return (dateFormatter.string(from: date), nil) case .day, .month, .year: return (dateFormatter.string(from: date), nil) case .week: diff --git a/WordPress/Classes/ViewRelated/Stats/Shared Views/Post Stats/PostStatsTableViewController.swift b/WordPress/Classes/ViewRelated/Stats/Shared Views/Post Stats/PostStatsTableViewController.swift index 28d345daa3b0..9b3d05ff52b1 100644 --- a/WordPress/Classes/ViewRelated/Stats/Shared Views/Post Stats/PostStatsTableViewController.swift +++ b/WordPress/Classes/ViewRelated/Stats/Shared Views/Post Stats/PostStatsTableViewController.swift @@ -136,6 +136,10 @@ private extension PostStatsTableViewController { properties["post_id"] = postIdentifier } + if FeatureFlag.newStats.enabled { + properties[WPAnalyticsEvent.isNewStatsKey] = "1" + } + WPAppAnalytics.track(.statsAccessed, withProperties: properties) } diff --git a/WordPress/Classes/ViewRelated/Stats/SiteStatsDashboardViewController.swift b/WordPress/Classes/ViewRelated/Stats/SiteStatsDashboardViewController.swift index f53b326f6673..e5c2bd79db56 100644 --- a/WordPress/Classes/ViewRelated/Stats/SiteStatsDashboardViewController.swift +++ b/WordPress/Classes/ViewRelated/Stats/SiteStatsDashboardViewController.swift @@ -1,6 +1,10 @@ import UIKit import WordPressKit import WordPressShared +import WordPressData +import Combine +import TipKit +import BuildSettingsKit enum StatsTabType: Int, FilterTabBarItem, CaseIterable { case insights = 0 @@ -54,6 +58,9 @@ public class SiteStatsDashboardViewController: UIViewController { private var pageViewController: UIPageViewController? private lazy var displayedTabs: [StatsTabType] = StatsTabType.displayedTabs + private var tipObserver: TipObserver? + private var isUsingMockData = false + private var navigationItemObserver: NSKeyValueObservation? @objc public lazy var manageInsightsButton: UIBarButtonItem = { let button = UIBarButtonItem( @@ -65,6 +72,14 @@ public class SiteStatsDashboardViewController: UIViewController { return button }() + private lazy var statsMenuButton: UIBarButtonItem = { + let button = UIBarButtonItem( + image: UIImage(systemName: "ellipsis"), + menu: createStatsMenu() + ) + return button + }() + // MARK: - Stats View Controllers private lazy var insightsTableViewController = { @@ -74,7 +89,29 @@ public class SiteStatsDashboardViewController: UIViewController { return viewController }() - private lazy var trafficTableViewController = { + private lazy var trafficTableViewController: UIViewController = { + // If new stats is enabled, show StatsHostingViewController instead + if FeatureFlag.newStats.enabled { + return createNewTrafficViewController() ?? createClassicTrafficViewController() + } else { + return createClassicTrafficViewController() + } + }() + + private func createNewTrafficViewController() -> UIViewController? { + if isUsingMockData { + // Create with demo context for mock data + return StatsHostingViewController.makeNewTrafficViewController(blog: nil, parentViewController: self, isDemo: true) + } else { + guard let siteID = SiteStatsInformation.sharedInstance.siteID, + let blog = Blog.lookup(withID: siteID, in: ContextManager.shared.mainContext) else { + return nil + } + return StatsHostingViewController.makeNewTrafficViewController(blog: blog, parentViewController: self, isDemo: false) + } + } + + private func createClassicTrafficViewController() -> UIViewController { let date: Date if let selectedDate = SiteStatsDashboardPreferences.getLastSelectedDateFromUserDefaults() { date = selectedDate @@ -87,7 +124,7 @@ public class SiteStatsDashboardViewController: UIViewController { let viewController = SiteStatsPeriodTableViewController(date: date, period: currentPeriod) viewController.bannerView = jetpackBannerView return viewController - }() + } private lazy var subscribersViewController = { let viewModel = StatsSubscribersViewModel() @@ -96,6 +133,10 @@ public class SiteStatsDashboardViewController: UIViewController { // MARK: - View + deinit { + navigationItemObserver?.invalidate() + } + public override func viewDidLoad() { super.viewDidLoad() @@ -117,7 +158,40 @@ public class SiteStatsDashboardViewController: UIViewController { } func configureNavBar() { - parent?.navigationItem.rightBarButtonItem = currentSelectedTab == .insights ? manageInsightsButton : nil + // Clean up previous observer + navigationItemObserver?.invalidate() + navigationItemObserver = nil + + switch currentSelectedTab { + case .insights: + parent?.navigationItem.rightBarButtonItem = manageInsightsButton + case .traffic: + // Always show the menu for switching between stats experiences + statsMenuButton.menu = createStatsMenu() + + // Set up observer for navigation item changes + navigationItemObserver = trafficTableViewController.navigationItem.observe(\.trailingItemGroups, options: [.initial, .new]) { [weak self] navigationItem, _ in + guard let self else { return } + DispatchQueue.main.async { + self.updateParentNavigationItems(with: self.trafficTableViewController) + } + } + + // Show tip for new stats if available and not enabled + if #available(iOS 17, *), !FeatureFlag.newStats.enabled { + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(1)) { + self.showNewStatsTip() + } + } + default: + parent?.navigationItem.rightBarButtonItem = nil + } + } + + private func updateParentNavigationItems(with childVC: UIViewController) { + parent?.navigationItem.trailingItemGroups = childVC.navigationItem.trailingItemGroups + [ + UIBarButtonItemGroup.fixedGroup(items: [statsMenuButton]) + ] } func configureJetpackBanner() { @@ -136,6 +210,127 @@ public class SiteStatsDashboardViewController: UIViewController { insightsTableViewController.showAddInsightView(source: "nav_bar") } + private func createStatsMenu() -> UIMenu { + var menuElements: [UIMenuElement] = [] + + if FeatureFlag.newStats.enabled { + // Main actions + var mainActions: [UIMenuElement] = [] + + // Add "Switch to Classic Stats" option when new stats is enabled + let switchToClassicAction = UIAction( + title: Strings.switchToClassic, + image: UIImage(systemName: "arrow.uturn.backward") + ) { [weak self] _ in + self?.disableNewStats() + } + mainActions.append(switchToClassicAction) + + // Add "Send Feedback" option + let sendFeedbackAction = UIAction( + title: Strings.sendFeedback, + image: UIImage(systemName: "envelope") + ) { [weak self] _ in + self?.showFeedbackView() + } + mainActions.append(sendFeedbackAction) + + menuElements.append(contentsOf: mainActions) + + // Debug section (only in debug builds) + if BuildConfiguration.current == .debug { + let toggleDataSource = UIAction( + title: isUsingMockData ? "Use Real Data" : "Use Mock Data", + image: UIImage(systemName: "arrow.triangle.2.circlepath") + ) { [weak self] _ in + self?.toggleDataSource() + } + + let debugMenu = UIMenu(title: "Debug", options: .displayInline, children: [toggleDataSource]) + menuElements.append(debugMenu) + } + } else { + // Add "Try New Stats" option if feature is available but not enabled + let tryNewStatsAction = UIAction( + title: Strings.tryNewStats, + image: UIImage(systemName: "sparkles") + ) { [weak self] _ in + self?.enableNewStats() + } + menuElements.append(tryNewStatsAction) + } + + return UIMenu(children: menuElements) + } + + private func enableNewStats() { + WPAnalytics.track(.statsNewStatsEnabled) + + FeatureFlagOverrideStore().override(FeatureFlag.newStats, withValue: true) + + // Update the traffic view controller to show new stats + guard let trafficVC = createNewTrafficViewController() else { + return + } + + trafficTableViewController = trafficVC + pageViewController?.setViewControllers([trafficTableViewController], direction: .forward, animated: false) + configureNavBar() + } + + private func disableNewStats() { + WPAnalytics.track(.statsNewStatsDisabled) + + FeatureFlagOverrideStore().override(FeatureFlag.newStats, withValue: false) + + trafficTableViewController = createClassicTrafficViewController() + pageViewController?.setViewControllers([trafficTableViewController], direction: .forward, animated: false) + configureNavBar() + } + + private func toggleDataSource() { + isUsingMockData.toggle() + + // Update the traffic view controller with new data source + guard let trafficVC = createNewTrafficViewController() else { + return + } + + trafficTableViewController = trafficVC + pageViewController?.setViewControllers([trafficTableViewController], direction: .forward, animated: false) + + // Update menu to reflect new state + statsMenuButton.menu = createStatsMenu() + + // Show notice indicating the change + let message = isUsingMockData ? "Using mock data" : "Using real data" + Notice(title: message).post() + } + + @available(iOS 17, *) + private func showNewStatsTip() { + guard let button = parent?.navigationItem.rightBarButtonItem else { return } + + tipObserver?.cancel() + tipObserver = registerTipPopover( + AppTips.NewStatsTip(), + sourceItem: button, + arrowDirection: .up + ) { [weak self] action in + guard let self else { return } + if action.id == "try-new-stats" { + self.enableNewStats() + if self.presentedViewController is TipUIPopoverViewController { + self.dismiss(animated: true) + } + } + } + } + + private func showFeedbackView() { + present(SubmitFeedbackViewController(source: "new_stats", feedbackPrefix: "Stats"), animated: true) + } + public override func viewWillDisappear(_ animated: Bool) { super.viewWillDisappear(animated) removeWillEnterForegroundObserver() @@ -187,17 +382,16 @@ private extension SiteStatsDashboardViewController { private extension SiteStatsDashboardViewController { func setupFilterBar() { - WPStyleGuide.Stats.configureFilterTabBar(filterTabBar) - filterTabBar.isAutomaticTabSizingStyleEnabled = true + WPStyleGuide.configureFilterTabBar(filterTabBar) + filterTabBar.configureModernStyle() filterTabBar.items = displayedTabs filterTabBar.addTarget(self, action: #selector(selectedFilterDidChange(_:)), for: .valueChanged) filterTabBar.accessibilityIdentifier = "site-stats-dashboard-filter-bar" - filterTabBar.backgroundColor = .systemBackground } @objc func selectedFilterDidChange(_ filterBar: FilterTabBar) { currentSelectedTab = displayedTabs[filterBar.selectedIndex] - + UIImpactFeedbackGenerator(style: .soft).impactOccurred() configureNavBar() } } @@ -255,7 +449,9 @@ private extension SiteStatsDashboardViewController { direction: .forward, animated: false) } else { - trafficTableViewController.refreshData() + if let periodVC = trafficTableViewController as? SiteStatsPeriodTableViewController { + periodVC.refreshData() + } } case .subscribers: if oldSelectedTab != .subscribers || pageViewControllerIsEmpty { @@ -345,3 +541,25 @@ struct SiteStatsDashboardPreferences { private static let lastSelectedStatsDateKey = "LastSelectedStatsDate" } + +// MARK: - Strings + +private enum Strings { + static let sendFeedback = NSLocalizedString( + "stats.menu.sendFeedback", + value: "Send Feedback", + comment: "Menu item to send feedback about new stats experience" + ) + + static let switchToClassic = NSLocalizedString( + "stats.menu.switchToClassic", + value: "Switch to Classic Stats", + comment: "Menu item to switch back to classic stats experience" + ) + + static let tryNewStats = NSLocalizedString( + "stats.menu.tryNewStats", + value: "Try New Stats", + comment: "Menu item to enable new stats experience" + ) +} diff --git a/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift b/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift new file mode 100644 index 000000000000..aca054081fc4 --- /dev/null +++ b/WordPress/Classes/ViewRelated/Stats/StatsHostingViewController.swift @@ -0,0 +1,150 @@ +import UIKit +import SwiftUI +import JetpackStats +import WordPressKit +import WordPressShared +import Gravatar +import BuildSettingsKit + +/// A UIViewController wrapper for the new SwiftUI StatsMainView +class StatsHostingViewController: UIViewController { + static func makeNewTrafficViewController(blog: Blog? = nil, parentViewController: UIViewController, isDemo: Bool = false) -> UIViewController? { + let context: StatsContext + if isDemo { + context = StatsContext.demo + } else { + guard let blog, let blogContext = StatsContext(blog: blog) else { + return nil + } + context = blogContext + } + + let statsView = StatsMainView( + context: context, + router: StatsRouter(viewController: parentViewController), + showTabs: false + ) + let hostingController = SafeAreaHostingController(rootView: statsView) + + return hostingController + } + + static func makeStatsViewController(for blog: Blog) -> UIViewController { + let statsVC = StatsViewController() + statsVC.blog = blog + statsVC.hidesBottomBarWhenPushed = true + statsVC.navigationItem.largeTitleDisplayMode = .never + return statsVC + } +} + +extension StatsContext { + init?(blog: Blog) { + guard let siteID = blog.dotComID?.intValue, + let api = blog.account?.wordPressComRestApi else { + wpAssertionFailure("required context missing") + return nil + } + self.init( + timeZone: blog.timeZone ?? .current, + siteID: siteID, + api: api + ) + + // Configure avatar preprocessing using Gravatar + self.preprocessAvatar = { url, size in + // Use AvatarURL from Gravatar to update the URL to the requested pixel size + guard let avatarURL = AvatarURL(url: url) else { + return url + } + let options = AvatarQueryOptions(preferredSize: .points(size)) + return avatarURL.replacing(options: options)?.url ?? url + } + + // Configure analytics tracker + self.tracker = WPAnalyticsStatsTracker() + } +} + +extension StatsRouter { + @MainActor + convenience init(viewController: UIViewController) { + self.init( + viewController: viewController, + factory: JetpackAppStatsRouterScreenFactory() + ) + } +} + +/// Shared router implementation for Jetpack app stats navigation +private final class JetpackAppStatsRouterScreenFactory: StatsRouterScreenFactory { + func makeLikesListViewController(siteID: Int, postID: Int, totalLikes: Int) -> UIViewController { + StatsLikesListViewController( + siteID: siteID as NSNumber, + postID: NSNumber(value: postID), + totalLikes: totalLikes + ) + } + + func makeCommentsListViewController(siteID: Int, postID: Int) -> UIViewController { + ReaderCommentsViewController( + postID: NSNumber(value: postID), + siteID: siteID as NSNumber + ) + } +} + +/// A custom UIHostingController that properly handles safe area insets when embedded in containers like UIPageViewController +private class SafeAreaHostingController: UIHostingController { + private var safeAreaObservation: NSKeyValueObservation? + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + setupSafeAreaObservation() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + safeAreaObservation?.invalidate() + safeAreaObservation = nil + } + + private func setupSafeAreaObservation() { + // Find the root view controller (should be SiteStatsDashboardViewController or its parent) + var rootViewController: UIViewController? = self + while let parent = rootViewController?.parent { + rootViewController = parent + } + + guard let rootView = rootViewController?.view else { return } + + // Observe changes to the root view's safe area insets + safeAreaObservation = rootView.observe(\.safeAreaInsets, options: [.initial, .new]) { [weak self] view, _ in + self?.updateSafeAreaInsets(from: view) + } + } + + private func updateSafeAreaInsets(from rootView: UIView) { + // Apply the root view's bottom safe area inset + let bottomInset = rootView.safeAreaInsets.bottom + if additionalSafeAreaInsets.bottom != bottomInset { + additionalSafeAreaInsets = UIEdgeInsets(top: 0, left: 0, bottom: min(20, bottomInset), right: 0) + } + } +} + +// MARK: - WPAnalyticsStatsTracker + +/// A StatsTracker implementation that bridges JetpackStats analytics to WPAnalytics +private final class WPAnalyticsStatsTracker: StatsTracker { + func send(_ event: StatsEvent, properties: [String: String]) { + // Convert String properties to [AnyHashable: Any] + let wpProperties: [AnyHashable: Any] = properties.reduce(into: [:]) { result, pair in + result[pair.key] = pair.value + } + + WPAnalytics.track(event.wpEvent, properties: wpProperties) + } +} diff --git a/WordPress/Classes/ViewRelated/Stats/StatsLikesListViewController.swift b/WordPress/Classes/ViewRelated/Stats/StatsLikesListViewController.swift new file mode 100644 index 000000000000..34571ab680fe --- /dev/null +++ b/WordPress/Classes/ViewRelated/Stats/StatsLikesListViewController.swift @@ -0,0 +1,105 @@ +import Foundation +import UIKit +import WordPressData +import WordPressUI + +/// A view controller that displays the list of users who liked a post from the Stats screen. +class StatsLikesListViewController: UITableViewController, NoResultsViewHost { + + // MARK: - Properties + private let siteID: NSNumber + private let postID: NSNumber + private var likesListController: LikesListController? + private var totalLikes: Int + + // MARK: - Init + init(siteID: NSNumber, postID: NSNumber, totalLikes: Int) { + self.siteID = siteID + self.postID = postID + self.totalLikes = totalLikes + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - View + override func viewDidLoad() { + super.viewDidLoad() + + configureViewTitle() + configureTable() + WPAnalytics.track(.likeListOpened, properties: ["list_type": "post", "source": "stats_post_details"]) + } + +} + +private extension StatsLikesListViewController { + + func configureViewTitle() { + let titleFormat = totalLikes == 1 ? TitleFormats.singular : TitleFormats.plural + navigationItem.title = String(format: titleFormat, totalLikes) + } + + func configureTable() { + tableView.register(LikeUserTableViewCell.defaultNib, + forCellReuseIdentifier: LikeUserTableViewCell.defaultReuseID) + + likesListController = LikesListController( + tableView: tableView, + siteID: siteID, + postID: postID, + delegate: self + ) + tableView.delegate = likesListController + tableView.dataSource = likesListController + + // The separator is controlled by LikeUserTableViewCell + tableView.separatorStyle = .none + + // Call refresh to ensure that the controller fetches the data. + likesListController?.refresh() + } + + func displayUserProfile(_ user: LikeUser, from indexPath: IndexPath) { + let userProfileVC = UserProfileSheetViewController(user: user) + userProfileVC.blogUrlPreviewedSource = "stats_post_likes_list_user_profile" + userProfileVC.modalPresentationStyle = .popover + userProfileVC.popoverPresentationController?.sourceView = tableView.cellForRow(at: indexPath) ?? view + userProfileVC.popoverPresentationController?.adaptiveSheetPresentationController.prefersGrabberVisible = true + userProfileVC.popoverPresentationController?.adaptiveSheetPresentationController.detents = [.medium()] + present(userProfileVC, animated: true) + + WPAnalytics.track(.userProfileSheetShown, properties: ["source": "stats_post_likes_list"]) + } + + struct TitleFormats { + static let singular = NSLocalizedString("%1$d Like", + comment: "Singular format string for view title displaying the number of post likes. %1$d is the number of likes.") + static let plural = NSLocalizedString("%1$d Likes", + comment: "Plural format string for view title displaying the number of post likes. %1$d is the number of likes.") + } + +} + +// MARK: - LikesListController Delegate +// +extension StatsLikesListViewController: LikesListControllerDelegate { + + func didSelectUser(_ user: LikeUser, at indexPath: IndexPath) { + displayUserProfile(user, from: indexPath) + } + + func showErrorView(title: String, subtitle: String?) { + configureAndDisplayNoResults(on: tableView, + title: title, + subtitle: subtitle, + image: "wp-illustration-reader-empty") + } + + func updatedTotalLikes(_ totalLikes: Int) { + self.totalLikes = totalLikes + configureViewTitle() + } +} diff --git a/WordPress/Classes/ViewRelated/Stats/StatsViewController.h b/WordPress/Classes/ViewRelated/Stats/StatsViewController.h index c8178651f2c7..833593145927 100644 --- a/WordPress/Classes/ViewRelated/Stats/StatsViewController.h +++ b/WordPress/Classes/ViewRelated/Stats/StatsViewController.h @@ -7,6 +7,4 @@ @property (nonatomic, weak, nullable) Blog *blog; @property (nonatomic, copy, nullable) void (^dismissBlock)(void); -+ (void)showForBlog:(nonnull Blog *)blog from:(nonnull UIViewController *)controller; - @end diff --git a/WordPress/Classes/ViewRelated/Stats/StatsViewController.m b/WordPress/Classes/ViewRelated/Stats/StatsViewController.m index eb08c1e020da..27a63f92a15b 100644 --- a/WordPress/Classes/ViewRelated/Stats/StatsViewController.m +++ b/WordPress/Classes/ViewRelated/Stats/StatsViewController.m @@ -25,15 +25,6 @@ - (instancetype)init return self; } -+ (void)showForBlog:(Blog *)blog from:(UIViewController *)controller -{ - StatsViewController *statsController = [StatsViewController new]; - statsController.blog = blog; - statsController.hidesBottomBarWhenPushed = YES; - statsController.navigationItem.largeTitleDisplayMode = UINavigationItemLargeTitleDisplayModeNever; - [controller.navigationController pushViewController:statsController animated:YES]; -} - - (void)viewDidLoad { [super viewDidLoad]; diff --git a/WordPress/Classes/ViewRelated/Stats/StatsViewController.swift b/WordPress/Classes/ViewRelated/Stats/StatsViewController.swift index 387511cf450c..d9920058fac5 100644 --- a/WordPress/Classes/ViewRelated/Stats/StatsViewController.swift +++ b/WordPress/Classes/ViewRelated/Stats/StatsViewController.swift @@ -14,5 +14,4 @@ extension StatsViewController { controller.view.translatesAutoresizingMaskIntoConstraints = false controller.view.pinEdges() } - } diff --git a/WordPress/Classes/ViewRelated/Stats/Traffic/StatsTrafficDatePickerView.swift b/WordPress/Classes/ViewRelated/Stats/Traffic/StatsTrafficDatePickerView.swift index d5f1d034284f..fc26c768fed4 100644 --- a/WordPress/Classes/ViewRelated/Stats/Traffic/StatsTrafficDatePickerView.swift +++ b/WordPress/Classes/ViewRelated/Stats/Traffic/StatsTrafficDatePickerView.swift @@ -90,6 +90,8 @@ struct StatsTrafficDatePickerView: View { private extension StatsPeriodUnit { var label: String { switch self { + case .hour: + return NSLocalizedString("stats.traffic.hours", value: "Hours", comment: "The label for the option to show Stats Traffic chart for Days.") case .day: return NSLocalizedString("stats.traffic.days", value: "Days", comment: "The label for the option to show Stats Traffic chart for Days.") case .week: diff --git a/WordPress/Classes/ViewRelated/Stats/Traffic/StatsTrafficDatePickerViewModel.swift b/WordPress/Classes/ViewRelated/Stats/Traffic/StatsTrafficDatePickerViewModel.swift index bfb508ea09e1..73039797c2ed 100644 --- a/WordPress/Classes/ViewRelated/Stats/Traffic/StatsTrafficDatePickerViewModel.swift +++ b/WordPress/Classes/ViewRelated/Stats/Traffic/StatsTrafficDatePickerViewModel.swift @@ -73,6 +73,9 @@ private extension StatsPeriodUnit { var dateFormatter: DateFormatter { let format: String switch self { + case .hour: + wpAssertionFailure("unsupported") + format = "MMMM d, yyyy" case .day: format = "MMMM d, yyyy" case .week: @@ -89,6 +92,9 @@ private extension StatsPeriodUnit { var event: WPAnalyticsStat { switch self { + case .hour: + wpAssertionFailure("unsupported") + return .statsPeriodDaysAccessed case .day: return .statsPeriodDaysAccessed case .week: diff --git a/WordPress/Classes/ViewRelated/System/FilterTabBar.swift b/WordPress/Classes/ViewRelated/System/FilterTabBar.swift index f2191c3cb8c3..e921043ad45f 100644 --- a/WordPress/Classes/ViewRelated/System/FilterTabBar.swift +++ b/WordPress/Classes/ViewRelated/System/FilterTabBar.swift @@ -228,6 +228,28 @@ public class FilterTabBar: UIControl { var tabAttributedButtonInsets: UIEdgeInsets = AppearanceMetrics.buttonInsetsAttributedTitle var tabSeparatorPadding: CGFloat = AppearanceMetrics.buttonPadding + // MARK: - Modern Style Configuration + + func configureModernStyle() { + isAutomaticTabSizingStyleEnabled = true + + // Apply modern tab appearance with larger fonts and padding + tabsFont = UIFont.preferredFont(forTextStyle: .headline).withWeight(.regular) + tabsSelectedFont = UIFont.preferredFont(forTextStyle: .headline) + tabButtonInsets = UIEdgeInsets(top: 16, left: 20, bottom: 16, right: 20) + tabBarHeight = 54 + + tintColor = UIColor.label + selectedTitleColor = UIColor.label + deselectedTabColor = UIColor.secondaryLabel + backgroundColor = .systemBackground + + // Configure selection indicator for modern style + selectionIndicator.layer.cornerRadius = 2.0 + + refreshTabs() + } + // MARK: - Initialization public required init?(coder aDecoder: NSCoder) { super.init(coder: aDecoder) diff --git a/WordPress/Classes/ViewRelated/System/WPTabBarController.m b/WordPress/Classes/ViewRelated/System/WPTabBarController.m index 1b62b2a1c6b8..9f4b33ace7be 100644 --- a/WordPress/Classes/ViewRelated/System/WPTabBarController.m +++ b/WordPress/Classes/ViewRelated/System/WPTabBarController.m @@ -311,7 +311,7 @@ - (BOOL)tabBarController:(UITabBarController *)tabBarController shouldSelectView - (void)tabBar:(UITabBar *)tabBar didSelectItem:(UITabBarItem *)item { - UIImpactFeedbackGenerator *generator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleMedium]; + UIImpactFeedbackGenerator *generator = [[UIImpactFeedbackGenerator alloc] initWithStyle:UIImpactFeedbackStyleSoft]; [generator impactOccurred]; [self animateSelectedItem:item for:tabBar]; diff --git a/WordPress/Classes/ViewRelated/Tips/AppTips.swift b/WordPress/Classes/ViewRelated/Tips/AppTips.swift index 799410e5f8fb..897523b875b3 100644 --- a/WordPress/Classes/ViewRelated/Tips/AppTips.swift +++ b/WordPress/Classes/ViewRelated/Tips/AppTips.swift @@ -54,6 +54,35 @@ enum AppTips { MaxDisplayCount(1) } } + + @available(iOS 17, *) + struct NewStatsTip: Tip { + let id = "new_stats_tip" + + var title: Text { + Text(NSLocalizedString("tips.newStats.title", value: "Try New Stats", comment: "Tip for new stats feature")) + } + + var message: Text? { + Text(NSLocalizedString("tips.newStats.message", value: "Experience new sleek and powerful stats. Switch back whenever you like.", comment: "Tip for new stats feature")) + } + + var image: Image? { + Image(systemName: "wand.and.sparkles.inverse") + } + + var actions: [Action] { + Action(id: "try-new-stats", title: NSLocalizedString( + "tips.newStats.action", + value: "Enable Now", + comment: "Action button title to enable new stats from tip" + )) + } + + var options: [any TipOption] { + MaxDisplayCount(1) + } + } } extension UIViewController {