From 9741ace4ba84b412838956199e4183233aed011a Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Tue, 5 Aug 2025 11:05:12 -0400 Subject: [PATCH] Add TopListItemView --- .../Sources/JetpackStats/StatsContext.swift | 73 +++ .../Sources/JetpackStats/StatsRouter.swift | 99 +++++ .../Rows/TopListArchiveItemRowView.swift | 20 + .../Rows/TopListArchiveSectionRowView.swift | 25 ++ .../TopList/Rows/TopListAuthorRowView.swift | 24 + .../Rows/TopListExternalLinkRowView.swift | 27 ++ .../Rows/TopListFileDownloadRowView.swift | 13 + .../TopList/Rows/TopListLocationRowView.swift | 22 + .../TopList/Rows/TopListPostRowView.swift | 14 + .../TopList/Rows/TopListReferrerRowView.swift | 51 +++ .../Rows/TopListSearchTermRowView.swift | 13 + .../TopList/Rows/TopListVideoRowView.swift | 22 + .../TopList/TopListItemBarBackground.swift | 40 ++ .../TopList/TopListItemView+ContextMenu.swift | 215 +++++++++ .../Views/TopList/TopListItemView.swift | 415 ++++++++++++++++++ .../Views/TopList/TopListItemsView.swift | 61 +++ .../Views/TopList/TopListMetricsView.swift | 40 ++ 17 files changed, 1174 insertions(+) create mode 100644 Modules/Sources/JetpackStats/StatsContext.swift create mode 100644 Modules/Sources/JetpackStats/StatsRouter.swift create mode 100644 Modules/Sources/JetpackStats/Views/TopList/Rows/TopListArchiveItemRowView.swift create mode 100644 Modules/Sources/JetpackStats/Views/TopList/Rows/TopListArchiveSectionRowView.swift create mode 100644 Modules/Sources/JetpackStats/Views/TopList/Rows/TopListAuthorRowView.swift create mode 100644 Modules/Sources/JetpackStats/Views/TopList/Rows/TopListExternalLinkRowView.swift create mode 100644 Modules/Sources/JetpackStats/Views/TopList/Rows/TopListFileDownloadRowView.swift create mode 100644 Modules/Sources/JetpackStats/Views/TopList/Rows/TopListLocationRowView.swift create mode 100644 Modules/Sources/JetpackStats/Views/TopList/Rows/TopListPostRowView.swift create mode 100644 Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift create mode 100644 Modules/Sources/JetpackStats/Views/TopList/Rows/TopListSearchTermRowView.swift create mode 100644 Modules/Sources/JetpackStats/Views/TopList/Rows/TopListVideoRowView.swift create mode 100644 Modules/Sources/JetpackStats/Views/TopList/TopListItemBarBackground.swift create mode 100644 Modules/Sources/JetpackStats/Views/TopList/TopListItemView+ContextMenu.swift create mode 100644 Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift create mode 100644 Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift create mode 100644 Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift diff --git a/Modules/Sources/JetpackStats/StatsContext.swift b/Modules/Sources/JetpackStats/StatsContext.swift new file mode 100644 index 000000000000..6b31ebd9846e --- /dev/null +++ b/Modules/Sources/JetpackStats/StatsContext.swift @@ -0,0 +1,73 @@ +import Foundation +import SwiftUI +@preconcurrency import WordPressKit + +public struct StatsContext: Sendable { + /// The reporting time zone (the time zone of the site). + let timeZone: TimeZone + let calendar: Calendar + let service: any StatsServiceProtocol + let formatters: StatsFormatters + let siteID: Int + /// A closure to preprocess avatar URLs to request the appropriate pixel size. + public var preprocessAvatar: (@Sendable (URL, CGFloat) -> URL)? + /// Analytics tracker for monitoring user interactions + public var tracker: (any StatsTracker)? + + public init(timeZone: TimeZone, siteID: Int, api: WordPressComRestApi) { + self.init(timeZone: timeZone, siteID: siteID, service: StatsService(siteID: siteID, api: api, timeZone: timeZone)) + } + + init(timeZone: TimeZone, siteID: Int, service: (any StatsServiceProtocol)) { + self.siteID = siteID + self.timeZone = timeZone + self.calendar = { + var calendar = Calendar.current + calendar.timeZone = timeZone + return calendar + + }() + self.service = service + self.formatters = StatsFormatters(timeZone: timeZone) + self.preprocessAvatar = nil + self.tracker = nil + } + + public static let demo: StatsContext = { + var context = StatsContext(timeZone: .current, siteID: 1, service: MockStatsService()) + #if DEBUG + context.tracker = MockStatsTracker.shared + #endif + return context + }() + + /// Memoized formatted pre-configured to work with the reporting time zone. + final class StatsFormatters: Sendable { + let date: StatsDateFormatter + let dateRange: StatsDateRangeFormatter + + init(timeZone: TimeZone) { + self.date = StatsDateFormatter(timeZone: timeZone) + self.dateRange = StatsDateRangeFormatter(timeZone: timeZone) + } + } +} + +extension Calendar { + static var demo: Calendar { + StatsContext.demo.calendar + } +} + +// MARK: - Environment Key + +private struct StatsContextKey: EnvironmentKey { + static let defaultValue = StatsContext.demo +} + +extension EnvironmentValues { + var context: StatsContext { + get { self[StatsContextKey.self] } + set { self[StatsContextKey.self] = newValue } + } +} diff --git a/Modules/Sources/JetpackStats/StatsRouter.swift b/Modules/Sources/JetpackStats/StatsRouter.swift new file mode 100644 index 000000000000..bc17ce16679c --- /dev/null +++ b/Modules/Sources/JetpackStats/StatsRouter.swift @@ -0,0 +1,99 @@ +import SwiftUI +import UIKit +import SafariServices + +@MainActor +public protocol StatsRouterScreenFactory: AnyObject { + func makeLikesListViewController(siteID: Int, postID: Int, totalLikes: Int) -> UIViewController + func makeCommentsListViewController(siteID: Int, postID: Int) -> UIViewController +} + +public final class StatsRouter: @unchecked Sendable { + @MainActor + var navigationController: UINavigationController? { + let vc = viewController ?? findTopViewController() + return (vc as? UINavigationController) ?? vc?.navigationController + } + + public weak var viewController: UIViewController? + + let factory: StatsRouterScreenFactory + + public init(viewController: UIViewController? = nil, factory: StatsRouterScreenFactory) { + self.viewController = viewController + self.factory = factory + } + + @MainActor + private func findTopViewController() -> UIViewController? { + guard let window = UIApplication.shared.mainWindow else { + return nil + } + var topController = window.rootViewController + while let presented = topController?.presentedViewController { + topController = presented + } + return topController + } + + @MainActor + func navigate(to view: Content, title: String? = nil) { + let viewController = UIHostingController(rootView: view) + if let title { + // This ensures that it gets rendered instantly on navigation + viewController.title = title + } + navigationController?.pushViewController(viewController, animated: true) + } + + @MainActor + func navigateToLikesList(siteID: Int, postID: Int, totalLikes: Int) { + let likesVC = factory.makeLikesListViewController(siteID: siteID, postID: postID, totalLikes: totalLikes) + navigationController?.pushViewController(likesVC, animated: true) + } + + @MainActor + func navigateToCommentsList(siteID: Int, postID: Int) { + let commentsVC = factory.makeCommentsListViewController(siteID: siteID, postID: postID) + navigationController?.pushViewController(commentsVC, animated: true) + } + + @MainActor + func openURL(_ url: URL) { + // Open URL in in-app Safari + let safariViewController = SFSafariViewController(url: url) + let vc = viewController ?? findTopViewController() + vc?.present(safariViewController, animated: true) + } +} + +private extension UIApplication { + @objc var mainWindow: UIWindow? { + connectedScenes + .compactMap { ($0 as? UIWindowScene)?.keyWindow } + .first + } +} + +class MockStatsRouterScreenFactory: StatsRouterScreenFactory { + func makeCommentsListViewController(siteID: Int, postID: Int) -> UIViewController { + UIHostingController(rootView: Text(Strings.Errors.generic)) + } + + func makeLikesListViewController(siteID: Int, postID: Int, totalLikes: Int) -> UIViewController { + UIHostingController(rootView: Text(Strings.Errors.generic)) + } +} + +// MARK: - Environment Key + +private struct StatsRouterKey: EnvironmentKey { + static let defaultValue = StatsRouter(factory: MockStatsRouterScreenFactory()) +} + +extension EnvironmentValues { + var router: StatsRouter { + get { self[StatsRouterKey.self] } + set { self[StatsRouterKey.self] = newValue } + } +} diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListArchiveItemRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListArchiveItemRowView.swift new file mode 100644 index 000000000000..eb73332f600b --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListArchiveItemRowView.swift @@ -0,0 +1,20 @@ +import SwiftUI + +struct TopListArchiveItemRowView: View { + let item: TopListItem.ArchiveItem + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(item.value) + .font(.callout) + .foregroundColor(.primary) + .lineLimit(1) + + Text(item.href) + .font(.footnote) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } +} diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListArchiveSectionRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListArchiveSectionRowView.swift new file mode 100644 index 000000000000..49755aac0343 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListArchiveSectionRowView.swift @@ -0,0 +1,25 @@ +import SwiftUI + +struct TopListArchiveSectionRowView: View { + let item: TopListItem.ArchiveSection + + var body: some View { + HStack(spacing: Constants.step0_5) { + Image(systemName: "folder") + .font(.callout) + .foregroundColor(.secondary) + .frame(width: 24, alignment: .center) + + VStack(alignment: .leading, spacing: 2) { + Text(item.displayName) + .font(.callout) + .foregroundColor(.primary) + .lineLimit(1) + + Text(Strings.ArchiveSections.itemCount(item.items.count)) + .font(.footnote) + .foregroundColor(.secondary) + } + } + } +} diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListAuthorRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListAuthorRowView.swift new file mode 100644 index 000000000000..7b0b7e7c272f --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListAuthorRowView.swift @@ -0,0 +1,24 @@ +import SwiftUI + +struct TopListAuthorRowView: View { + let item: TopListItem.Author + + var body: some View { + HStack(spacing: Constants.step0_5) { + AvatarView(name: item.name, imageURL: item.avatarURL) + + VStack(alignment: .leading, spacing: 1) { + Text(item.name) + .font(.body) + .foregroundColor(.primary) + + if let role = item.role { + Text(role) + .font(.caption) + .foregroundColor(.secondary) + } + } + .lineLimit(1) + } + } +} diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListExternalLinkRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListExternalLinkRowView.swift new file mode 100644 index 000000000000..9479c5ece7a0 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListExternalLinkRowView.swift @@ -0,0 +1,27 @@ +import SwiftUI + +struct TopListExternalLinkRowView: View { + let item: TopListItem.ExternalLink + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + Text(item.title ?? item.url) + .font(.callout) + .foregroundColor(.primary) + .lineLimit(1) + + if item.children.count > 0 { + Text(Strings.ArchiveSections.itemCount(item.children.count)) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } else { + Text(item.url) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + } +} diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListFileDownloadRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListFileDownloadRowView.swift new file mode 100644 index 000000000000..d23c92399b89 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListFileDownloadRowView.swift @@ -0,0 +1,13 @@ +import SwiftUI + +struct TopListFileDownloadRowView: View { + let item: TopListItem.FileDownload + + var body: some View { + Text(item.fileName) + .font(.callout) + .foregroundColor(.primary) + .lineLimit(2) + .lineSpacing(-2) + } +} diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListLocationRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListLocationRowView.swift new file mode 100644 index 000000000000..10f12526f5e0 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListLocationRowView.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct TopListLocationRowView: View { + let item: TopListItem.Location + + var body: some View { + HStack(spacing: Constants.step0_5) { + if let flag = item.flag { + Text(flag) + .font(.title2) + } else { + Image(systemName: "map") + .font(.body) + .foregroundStyle(.secondary) + } + Text(item.country) + .font(.body) + .foregroundColor(.primary) + } + .lineLimit(1) + } +} diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListPostRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListPostRowView.swift new file mode 100644 index 000000000000..13bd7f18b2ca --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListPostRowView.swift @@ -0,0 +1,14 @@ +import SwiftUI +import WordPressShared + +struct TopListPostRowView: View { + let item: TopListItem.Post + + var body: some View { + Text(item.title) + .font(.callout) + .foregroundColor(.primary) + .lineSpacing(-2) + .lineLimit(2) + } +} diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift new file mode 100644 index 000000000000..033439bfae15 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListReferrerRowView.swift @@ -0,0 +1,51 @@ +import SwiftUI +import WordPressUI + +struct TopListReferrerRowView: View { + let item: TopListItem.Referrer + + var body: some View { + HStack(spacing: Constants.step0_5) { + // Icon or placeholder + if let iconURL = item.iconURL { + CachedAsyncImage(url: iconURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fit) + } placeholder: { + placeholderIcon + } + .frame(width: 24, height: 24) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } else { + placeholderIcon + .frame(width: 24, height: 24) + } + + VStack(alignment: .leading, spacing: 1) { + Text(item.name) + .font(.body) + .foregroundColor(.primary) + .lineLimit(1) + + HStack(spacing: 0) { + if let domain = item.domain { + Text(verbatim: domain) + .font(.caption) + } + if !item.children.isEmpty { + let prefix = item.domain == nil ? "" : "," + Text(verbatim: "\(prefix) +\(item.children.count)") + .font(.caption) + } + } + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } + + private var placeholderIcon: some View { + Image(systemName: "link") + } +} diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListSearchTermRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListSearchTermRowView.swift new file mode 100644 index 000000000000..774472f8af46 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListSearchTermRowView.swift @@ -0,0 +1,13 @@ +import SwiftUI + +struct TopListSearchTermRowView: View { + let item: TopListItem.SearchTerm + + var body: some View { + Text(item.term) + .font(.callout) + .foregroundColor(.primary) + .lineLimit(2) + .lineSpacing(-2) + } +} diff --git a/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListVideoRowView.swift b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListVideoRowView.swift new file mode 100644 index 000000000000..adc3dd041e04 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/Rows/TopListVideoRowView.swift @@ -0,0 +1,22 @@ +import SwiftUI + +struct TopListVideoRowView: View { + let item: TopListItem.Video + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + (Text(Image(systemName: "play.circle")).font(.footnote) + Text(" ") + Text(item.title)) + .font(.callout) + .foregroundColor(.primary) + .lineLimit(1) + + if let videoURL = item.videoURL?.absoluteString, !videoURL.isEmpty { + Text(videoURL) + .font(.footnote) + .truncationMode(.middle) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } +} diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemBarBackground.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemBarBackground.swift new file mode 100644 index 000000000000..6d709826a4c5 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemBarBackground.swift @@ -0,0 +1,40 @@ +import SwiftUI + +struct TopListItemBarBackground: View { + let value: Int + let maxValue: Int + let barColor: Color + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + GeometryReader { geometry in + HStack(spacing: 0) { + LinearGradient( + colors: [ + barColor.opacity(colorScheme == .light ? 0.06 : 0.22), + barColor.opacity(colorScheme == .light ? 0.12 : 0.35), + ], + startPoint: .leading, + endPoint: .trailing + ) + .mask( + HStack(spacing: 0) { + RoundedRectangle(cornerRadius: Constants.step1) + .frame(width: max(8, barWidth(in: geometry))) + Spacer(minLength: 0) + } + ) + Spacer(minLength: 0) + } + } + } + + private func barWidth(in geometry: GeometryProxy) -> CGFloat { + guard maxValue > 0 else { + return 0 + } + let value = geometry.size.width * CGFloat(value) / CGFloat(maxValue) + return max(0, value) + } +} diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView+ContextMenu.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView+ContextMenu.swift new file mode 100644 index 000000000000..bb8b950dc4af --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView+ContextMenu.swift @@ -0,0 +1,215 @@ +import SwiftUI +import UIKit + +// MARK: - Context Menu + +extension TopListItemView { + @ViewBuilder + var contextMenuContent: some View { + Group { + // Item-specific actions + switch item { + case let post as TopListItem.Post: + postActions(post) + case let author as TopListItem.Author: + authorActions(author) + case let referrer as TopListItem.Referrer: + referrerActions(referrer) + case let location as TopListItem.Location: + locationActions(location) + case let link as TopListItem.ExternalLink: + externalLinkActions(link) + case let download as TopListItem.FileDownload: + fileDownloadActions(download) + case let searchTerm as TopListItem.SearchTerm: + searchTermActions(searchTerm) + case let video as TopListItem.Video: + videoActions(video) + case let archiveItem as TopListItem.ArchiveItem: + archiveItemActions(archiveItem) + case let archiveSection as TopListItem.ArchiveSection: + archiveSectionActions(archiveSection) + default: + EmptyView() + } + } + } + + // MARK: - Post Actions + + @ViewBuilder + func postActions(_ post: TopListItem.Post) -> some View { + if let url = post.postURL { + Button { + router.openURL(url) + } label: { + Label(Strings.ContextMenuActions.openInBrowser, systemImage: "safari") + } + + Button { + UIPasteboard.general.url = url + } label: { + Label(Strings.ContextMenuActions.copyURL, systemImage: "doc.on.doc") + } + } + + Button { + UIPasteboard.general.string = post.title + } label: { + Label(Strings.ContextMenuActions.copyTitle, systemImage: "doc.on.doc") + } + } + + // MARK: - Author Actions + + @ViewBuilder + func authorActions(_ author: TopListItem.Author) -> some View { + Button { + UIPasteboard.general.string = author.name + } label: { + Label(Strings.ContextMenuActions.copyName, systemImage: "doc.on.doc") + } + } + + // MARK: - Referrer Actions + + @ViewBuilder + func referrerActions(_ referrer: TopListItem.Referrer) -> some View { + if let domain = referrer.domain { + Button { + if let url = URL(string: "https://\(domain)") { + router.openURL(url) + } + } label: { + Label(Strings.ContextMenuActions.openInBrowser, systemImage: "safari") + } + + Button { + UIPasteboard.general.string = domain + } label: { + Label(Strings.ContextMenuActions.copyDomain, systemImage: "doc.on.doc") + } + } + } + + // MARK: - Location Actions + + @ViewBuilder + func locationActions(_ location: TopListItem.Location) -> some View { + Button { + UIPasteboard.general.string = location.country + } label: { + Label(Strings.ContextMenuActions.copyCountryName, systemImage: "doc.on.doc") + } + } + + // MARK: - External Link Actions + + @ViewBuilder + func externalLinkActions(_ link: TopListItem.ExternalLink) -> some View { + if let url = URL(string: link.url) { + Button { + router.openURL(url) + } label: { + Label(Strings.ContextMenuActions.openInBrowser, systemImage: "safari") + } + } + + Button { + UIPasteboard.general.string = link.url + } label: { + Label("Copy URL", systemImage: "doc.on.doc") + } + } + + // MARK: - File Download Actions + + @ViewBuilder + func fileDownloadActions(_ download: TopListItem.FileDownload) -> some View { + Button { + UIPasteboard.general.string = download.fileName + } label: { + Label(Strings.ContextMenuActions.copyFileName, systemImage: "doc.on.doc") + } + + if let path = download.filePath { + Button { + UIPasteboard.general.string = path + } label: { + Label(Strings.ContextMenuActions.copyFilePath, systemImage: "doc.on.doc") + } + } + } + + // MARK: - Search Term Actions + + @ViewBuilder + func searchTermActions(_ searchTerm: TopListItem.SearchTerm) -> some View { + Button { + let query = searchTerm.term.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + if let url = URL(string: "https://www.google.com/search?q=\(query)") { + router.openURL(url) + } + } label: { + Label(Strings.ContextMenuActions.searchInGoogle, systemImage: "magnifyingglass") + } + + Button { + UIPasteboard.general.string = searchTerm.term + } label: { + Label(Strings.ContextMenuActions.copySearchTerm, systemImage: "doc.on.doc") + } + } + + // MARK: - Video Actions + + @ViewBuilder + func videoActions(_ video: TopListItem.Video) -> some View { + if let url = video.videoURL { + Button { + router.openURL(url) + } label: { + Label(Strings.ContextMenuActions.openInBrowser, systemImage: "safari") + } + + Button { + UIPasteboard.general.url = url + } label: { + Label(Strings.ContextMenuActions.copyVideoURL, systemImage: "doc.on.doc") + } + } + + Button { + UIPasteboard.general.string = video.title + } label: { + Label(Strings.ContextMenuActions.copyTitle, systemImage: "doc.on.doc") + } + } + + // MARK: - Archive Item Actions + + @ViewBuilder + func archiveItemActions(_ archiveItem: TopListItem.ArchiveItem) -> some View { + if let url = URL(string: archiveItem.href) { + Button { + router.openURL(url) + } label: { + Label(Strings.ContextMenuActions.openInBrowser, systemImage: "safari") + } + } + + Button { + UIPasteboard.general.string = archiveItem.href + } label: { + Label("Copy URL", systemImage: "doc.on.doc") + } + } + + // MARK: - Archive Section Actions + + @ViewBuilder + func archiveSectionActions(_ section: TopListItem.ArchiveSection) -> some View { + // No specific actions for archive sections + EmptyView() + } +} diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift new file mode 100644 index 000000000000..8ddf33792cdc --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemView.swift @@ -0,0 +1,415 @@ +import SwiftUI +import DesignSystem + +struct TopListItemView: View { + static let defaultCellHeight: CGFloat = 52 + + let item: any TopListItemProtocol + let previousValue: Int? + let metric: SiteMetric + let maxValue: Int + let dateRange: StatsDateRange + + @State private var isTapped = false + + /// .title scales the bets in this scenario + @ScaledMetric(relativeTo: .title) private var cellHeight = TopListItemView.defaultCellHeight + @ScaledMetric(relativeTo: .title) private var minTrailingWidth = 74 + + @Environment(\.router) var router + @Environment(\.context) var context + + var body: some View { + if hasDetails { + Button { + // Track item tap + trackItemTap() + + // Trigger animation + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + isTapped = true + } + + // Reset after a delay + DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { + withAnimation(.spring(response: 0.3, dampingFraction: 0.7)) { + isTapped = false + } + } + navigateToDetails() + } label: { + content + .contentShape(Rectangle()) // Make the entire view tappable + .scaleEffect(isTapped ? 0.97 : 1.0) + .opacity(isTapped ? 0.85 : 1.0) + } + .buttonStyle(.plain) + .accessibilityHint(Strings.Accessibility.viewMoreDetails) + } else { + content + } + } + + var content: some View { + HStack(alignment: .center, spacing: 0) { + // Content-specific view + switch item { + case let post as TopListItem.Post: + TopListPostRowView(item: post) + case let author as TopListItem.Author: + TopListAuthorRowView(item: author) + case let referrer as TopListItem.Referrer: + TopListReferrerRowView(item: referrer) + case let location as TopListItem.Location: + TopListLocationRowView(item: location) + case let link as TopListItem.ExternalLink: + TopListExternalLinkRowView(item: link) + case let download as TopListItem.FileDownload: + TopListFileDownloadRowView(item: download) + case let searchTerm as TopListItem.SearchTerm: + TopListSearchTermRowView(item: searchTerm) + case let video as TopListItem.Video: + TopListVideoRowView(item: video) + case let archiveItem as TopListItem.ArchiveItem: + TopListArchiveItemRowView(item: archiveItem) + case let archiveSection as TopListItem.ArchiveSection: + TopListArchiveSectionRowView(item: archiveSection) + default: + let _ = assertionFailure("unsupported item: \(item)") + EmptyView() + } + + Spacer(minLength: 6) + + // Metrics view + TopListMetricsView( + currentValue: item.metrics[metric] ?? 0, + previousValue: previousValue, + metric: metric, + showChevron: hasDetails + ) + .frame(minWidth: previousValue == nil ? 20 : minTrailingWidth, alignment: .trailing) + .padding(.trailing, -3) + .dynamicTypeSize(...DynamicTypeSize.xLarge) + } + .dynamicTypeSize(...DynamicTypeSize.xxLarge) + .padding(.horizontal, Constants.step1) + .frame(height: cellHeight) + .contentShape(Rectangle()) + .accessibilityElement(children: .combine) + .contextMenu { + contextMenuContent + } + .background( + TopListItemBarBackground( + value: item.metrics[metric] ?? 0, + maxValue: maxValue, + barColor: metric.primaryColor + ) + ) + } +} + +// MARK: - Private Methods + +private extension TopListItemView { + var hasDetails: Bool { + switch item { + case is TopListItem.Post: + return true + case is TopListItem.ArchiveItem: + return true + case is TopListItem.ArchiveSection: + return true + case is TopListItem.Author: + return true + case is TopListItem.Referrer: + return true + case is TopListItem.ExternalLink: + return true + default: + return false + } + } + + func trackItemTap() { + context.tracker?.send(.topListItemTapped, properties: [ + "item_type": item.id.type.analyticsName, + "metric": metric.analyticsName + ]) + } + + func navigateToDetails() { + switch item { + case let post as TopListItem.Post: + let detailsView = PostStatsView(post: post, dateRange: dateRange) + .environment(\.context, context) + .environment(\.router, router) + router.navigate(to: detailsView, title: Strings.PostDetails.title) + case let archiveItem as TopListItem.ArchiveItem: + if let url = URL(string: archiveItem.href) { + router.openURL(url) + } + case let author as TopListItem.Author: + let detailsView = AuthorStatsView(author: author, initialDateRange: dateRange, context: context) + .environment(\.context, context) + .environment(\.router, router) + router.navigate(to: detailsView, title: Strings.AuthorDetails.title) + case let referrer as TopListItem.Referrer: + let detailsView = ReferrerStatsView(referrer: referrer, dateRange: dateRange) + .environment(\.context, context) + .environment(\.router, router) + router.navigate(to: detailsView, title: Strings.ReferrerDetails.title) + case let archiveSection as TopListItem.ArchiveSection: + let detailsView = ArchiveStatsView(archiveSection: archiveSection, dateRange: dateRange) + .environment(\.context, context) + .environment(\.router, router) + router.navigate(to: detailsView, title: archiveSection.displayName) + case let externalLink as TopListItem.ExternalLink: + let detailsView = ExternalLinkStatsView(externalLink: externalLink, dateRange: dateRange) + .environment(\.context, context) + .environment(\.router, router) + router.navigate(to: detailsView, title: Strings.ExternalLinkDetails.title) + default: + break + } + } +} + +// MARK: - Preview + +#Preview { + ScrollView { + VStack(spacing: 24) { + makePreviewItems() + } + .padding(Constants.step1) + } +} + +@MainActor @ViewBuilder +private func makePreviewItems() -> some View { + // Posts & Pages + VStack(spacing: 8) { + makePreviewItem( + TopListItem.Post( + title: "Getting Started with SwiftUI: A Comprehensive Guide", + postID: "1234", + postURL: URL(string: "https://example.com/swiftui-guide"), + date: Date().addingTimeInterval(-86400), + type: "post", + author: "John Doe", + metrics: SiteMetricsSet(views: 50000) + ), + previousValue: 45000 + ) + + makePreviewItem( + TopListItem.Post( + title: "About Us", + postID: "5678", + postURL: nil, + date: nil, + type: "page", + author: nil, + metrics: SiteMetricsSet(views: 3421) + ), + previousValue: 3500 + ) + } + + // Authors + VStack(spacing: 8) { + makePreviewItem( + TopListItem.Author( + name: "Sarah Johnson", + userId: "100", + role: nil, // Real API doesn't have roles + metrics: SiteMetricsSet(views: 50000), + avatarURL: Bundle.module.url(forResource: "author4", withExtension: "jpg"), + posts: nil + ), + previousValue: 48000 + ) + + makePreviewItem( + TopListItem.Author( + name: "Michael Chen", + userId: "101", + role: nil, + metrics: SiteMetricsSet(views: 23100), + avatarURL: nil, + posts: nil + ), + previousValue: nil + ) + } + + // Referrers + VStack(spacing: 8) { + makePreviewItem( + TopListItem.Referrer( + name: "Google Search", + domain: "google.com", + iconURL: URL(string: "https://www.google.com/favicon.ico"), + children: [], + metrics: SiteMetricsSet(views: 50000) + ), + previousValue: 42000 + ) + + makePreviewItem( + TopListItem.Referrer( + name: "Direct Traffic", + domain: nil, + iconURL: nil, + children: [], + metrics: SiteMetricsSet(views: 12300) + ), + previousValue: 15000 + ) + } + + // Locations + VStack(spacing: 8) { + makePreviewItem( + TopListItem.Location( + country: "United States", + flag: "πŸ‡ΊπŸ‡Έ", + countryCode: "US", + metrics: SiteMetricsSet(views: 50000) + ), + previousValue: 47500 + ) + + makePreviewItem( + TopListItem.Location( + country: "United Kingdom", + flag: "πŸ‡¬πŸ‡§", + countryCode: "GB", + metrics: SiteMetricsSet(views: 15600) + ), + previousValue: nil + ) + } + + // External Links + VStack(spacing: 8) { + makePreviewItem( + TopListItem.ExternalLink( + url: "https://developer.apple.com/documentation/swiftui", + title: "SwiftUI Documentation", + children: [], + metrics: SiteMetricsSet(views: 50000) + ), + previousValue: 52000 + ) + + makePreviewItem( + TopListItem.ExternalLink( + url: "https://github.com/wordpress/wordpress-ios", + title: nil, + children: [], + metrics: SiteMetricsSet(views: 1250) + ), + previousValue: 1100 + ) + } + + // File Downloads + VStack(spacing: 8) { + makePreviewItem( + TopListItem.FileDownload( + fileName: "wordpress-guide-2024.pdf", + filePath: "/downloads/guides/wordpress-guide-2024.pdf", + metrics: SiteMetricsSet(downloads: 50000) + ), + previousValue: 46000, + metric: .downloads + ) + + makePreviewItem( + TopListItem.FileDownload( + fileName: "sample-theme.zip", + filePath: nil, + metrics: SiteMetricsSet(downloads: 1230) + ), + previousValue: nil, + metric: .downloads + ) + } + + // Search Terms + VStack(spacing: 8) { + makePreviewItem( + TopListItem.SearchTerm( + term: "wordpress tutorial", + metrics: SiteMetricsSet(views: 50000) + ), + previousValue: 48500 + ) + + makePreviewItem( + TopListItem.SearchTerm( + term: "how to install plugins", + metrics: SiteMetricsSet(views: 890) + ), + previousValue: 950 + ) + } + + // Videos + VStack(spacing: 8) { + makePreviewItem( + TopListItem.Video( + title: "WordPress 6.0 Features Overview", + postId: "9012", + videoURL: URL(string: "https://example.com/videos/wp-6-features"), + metrics: SiteMetricsSet(views: 50000) + ), + previousValue: 44000 + ) + + makePreviewItem( + TopListItem.Video( + title: "Building Your First Theme", + postId: "9013", + videoURL: nil, + metrics: SiteMetricsSet(views: 3210) + ), + previousValue: nil + ) + } + + // Archive Items + VStack(spacing: 8) { + makePreviewItem( + TopListItem.ArchiveItem( + href: "/2024/03/", + value: "March 2024", + metrics: SiteMetricsSet(views: 50000) + ), + previousValue: 51000 + ) + + makePreviewItem( + TopListItem.ArchiveItem( + href: "/category/tutorials/", + value: "Tutorials", + metrics: SiteMetricsSet(views: 12300) + ), + previousValue: 11000 + ) + } +} + +@MainActor +private func makePreviewItem(_ item: any TopListItemProtocol, previousValue: Int? = nil, metric: SiteMetric = .views) -> some View { + TopListItemView( + item: item, + previousValue: previousValue, + metric: metric, + maxValue: 50000, + dateRange: Calendar.demo.makeDateRange(for: .last7Days) + ) +} diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift new file mode 100644 index 000000000000..5ade11730209 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListItemsView.swift @@ -0,0 +1,61 @@ +import SwiftUI + +struct TopListItemsView: View { + let data: TopListData + let itemLimit: Int + let dateRange: StatsDateRange + var reserveSpace: Bool = false + + @ScaledMetric(relativeTo: .callout) private var cellHeight = 52 + + var body: some View { + VStack(spacing: Constants.step1 / 2) { + ForEach(Array(data.items.prefix(itemLimit).enumerated()), id: \.element.id) { index, item in + makeView(for: item) + .transition(.move(edge: .leading) + .combined(with: .scale(scale: 0.75)) + .combined(with: .opacity)) + } + + if reserveSpace && data.items.count < itemLimit { + ForEach(0..<(itemLimit - data.items.count), id: \.self) { _ in + PlaceholderRowView(height: cellHeight) + } + } + } + .padding(.horizontal, Constants.step1) + .animation(.spring, value: ObjectIdentifier(data)) + } + + private func makeView(for item: any TopListItemProtocol) -> some View { + TopListItemView( + item: item, + previousValue: data.previousItem(for: item)?.metrics[data.metric], + metric: data.metric, + maxValue: data.metrics.maxValue, + dateRange: dateRange + ) + .frame(height: cellHeight) + } +} + +struct PlaceholderRowView: View { + let height: CGFloat + + var body: some View { + Rectangle() + .fill(Color.clear) + .background( + LinearGradient( + colors: [ + Color.secondary.opacity(0.05), + Color.secondary.opacity(0.02) + ], + startPoint: .leading, + endPoint: .trailing + ) + .clipShape(RoundedRectangle(cornerRadius: Constants.step1)) + ) + .frame(height: height) + } +} diff --git a/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift b/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift new file mode 100644 index 000000000000..ee216faab066 --- /dev/null +++ b/Modules/Sources/JetpackStats/Views/TopList/TopListMetricsView.swift @@ -0,0 +1,40 @@ +import SwiftUI + +struct TopListMetricsView: View { + let currentValue: Int + let previousValue: Int? + let metric: SiteMetric + var showChevron = false + + var body: some View { + VStack(alignment: .trailing, spacing: 2) { + HStack(spacing: 3) { + Text(StatsValueFormatter.formatNumber(currentValue, onlyLarge: true)) + .font(.system(.subheadline, design: .rounded, weight: .medium)).tracking(-0.1) + .foregroundColor(.primary) + .contentTransition(.numericText()) + + if showChevron { + Image(systemName: "chevron.forward") + .font(.caption2.weight(.bold)) + .foregroundStyle(Color(.tertiaryLabel)) + .padding(.trailing, -2) + } + } + if let trend { + Text(trend.formattedTrend) + .foregroundColor(trend.sentiment.foregroundColor) + .contentTransition(.numericText()) + .font(.system(.caption, design: .rounded, weight: .medium)).tracking(-0.33) + } + } + .animation(.spring, value: trend) + } + + private var trend: TrendViewModel? { + guard let previousValue else { + return nil + } + return TrendViewModel(currentValue: currentValue, previousValue: previousValue, metric: metric) + } +}