Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 73 additions & 0 deletions Modules/Sources/JetpackStats/StatsContext.swift
Original file line number Diff line number Diff line change
@@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems dangerous to use a mock value as the default value.

}

extension EnvironmentValues {
var context: StatsContext {
get { self[StatsContextKey.self] }
set { self[StatsContextKey.self] = newValue }
}
}
99 changes: 99 additions & 0 deletions Modules/Sources/JetpackStats/StatsRouter.swift
Original file line number Diff line number Diff line change
@@ -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<Content: View>(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 }
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Original file line number Diff line number Diff line change
@@ -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")
}
}
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading