Skip to content

Commit 38a433d

Browse files
committed
Add JetpackStats
1 parent f0bdcae commit 38a433d

File tree

104 files changed

+10009
-8
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

104 files changed

+10009
-8
lines changed

Modules/Package.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ let package = Package(
1111
.library(name: "AsyncImageKit", targets: ["AsyncImageKit"]),
1212
.library(name: "DesignSystem", targets: ["DesignSystem"]),
1313
.library(name: "FormattableContentKit", targets: ["FormattableContentKit"]),
14+
.library(name: "JetpackStats", targets: ["JetpackStats"]),
1415
.library(name: "JetpackStatsWidgetsCore", targets: ["JetpackStatsWidgetsCore"]),
1516
.library(name: "NotificationServiceExtensionCore", targets: ["NotificationServiceExtensionCore"]),
1617
.library(name: "ShareExtensionCore", targets: ["ShareExtensionCore"]),
@@ -93,6 +94,14 @@ let package = Package(
9394
// Set to v5 to avoid @Sendable warnings and errors
9495
swiftSettings: [.swiftLanguageMode(.v5)]
9596
),
97+
.target(
98+
name: "JetpackStats",
99+
dependencies: [
100+
"WordPressUI",
101+
.product(name: "WordPressKit", package: "WordPressKit-iOS"),
102+
],
103+
resources: [.process("Resources")]
104+
),
96105
.target(name: "JetpackStatsWidgetsCore", swiftSettings: [.swiftLanguageMode(.v5)]),
97106
.target(
98107
name: "ShareExtensionCore",
@@ -171,6 +180,7 @@ let package = Package(
171180
dependencies: ["AsyncImageKit", "WordPressUI", "WordPressShared"],
172181
resources: [.process("Resources")]
173182
),
183+
.testTarget(name: "JetpackStatsTests", dependencies: ["JetpackStats"]),
174184
.testTarget(name: "JetpackStatsWidgetsCoreTests", dependencies: [.target(name: "JetpackStatsWidgetsCore")], swiftSettings: [.swiftLanguageMode(.v5)]),
175185
.testTarget(name: "DesignSystemTests", dependencies: [.target(name: "DesignSystem")], swiftSettings: [.swiftLanguageMode(.v5)]),
176186
.testTarget(
@@ -276,6 +286,7 @@ enum XcodeSupport {
276286
"DesignSystem",
277287
"BuildSettingsKit",
278288
"FormattableContentKit",
289+
"JetpackStats",
279290
"JetpackStatsWidgetsCore",
280291
"NotificationServiceExtensionCore",
281292
"SFHFKeychainUtils",
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
1+
import SwiftUI
2+
import Charts
3+
4+
struct ChartCard: View {
5+
let metrics: [SiteMetric]
6+
let dateRange: StatsDateRange
7+
8+
@StateObject private var viewModel: ChartCardViewModel
9+
10+
@State private var selectedMetric: SiteMetric
11+
@State private var selectedChartType: ChartType = .line
12+
@State private var isShowingRawData = false
13+
14+
@ScaledMetric(relativeTo: .body) private var chartHeight = 180
15+
16+
init(metrics: [SiteMetric], dateRange: StatsDateRange, service: any StatsServiceProtocol) {
17+
self.metrics = metrics
18+
self.dateRange = dateRange
19+
20+
assert(metrics.count > 0)
21+
self._selectedMetric = .init(initialValue: metrics.first ?? .views)
22+
23+
let viewModel = ChartCardViewModel(metrics: metrics, service: service)
24+
self._viewModel = StateObject(wrappedValue: viewModel)
25+
26+
viewModel.loadData(for: dateRange)
27+
}
28+
29+
var body: some View {
30+
VStack(spacing: 0) {
31+
VStack(spacing: 6) {
32+
headerView(for: selectedMetric)
33+
.unredacted()
34+
contentView
35+
}
36+
.padding(Constants.step2)
37+
38+
if metrics.count > 1 {
39+
Divider()
40+
footerView
41+
}
42+
}
43+
.redacted(reason: viewModel.isFirstLoad ? .placeholder : [])
44+
.overlay(alignment: .topTrailing) {
45+
moreMenu
46+
}
47+
.grayscale(viewModel.isStale ? 1 : 0)
48+
.animation(.smooth, value: viewModel.isStale)
49+
.onChange(of: dateRange) { newRange in
50+
viewModel.loadData(for: newRange)
51+
}
52+
}
53+
54+
private func headerView(for metric: SiteMetric) -> some View {
55+
HStack {
56+
StatsCardTitleView(title: metric.localizedTitle, showChevron: false)
57+
Spacer(minLength: 44)
58+
}
59+
}
60+
61+
@ViewBuilder
62+
private var contentView: some View {
63+
Group {
64+
if viewModel.isFirstLoad {
65+
mainChartView(metric: selectedMetric, data: mockChartData)
66+
} else if let chartData = viewModel.chartData[selectedMetric] {
67+
mainChartView(metric: selectedMetric, data: chartData)
68+
} else if let error = viewModel.loadingError {
69+
mainChartView(metric: selectedMetric, data: mockChartData)
70+
.redacted(reason: .placeholder)
71+
.grayscale(1)
72+
.opacity(0.66)
73+
.overlay {
74+
SimpleErrorView(error: error)
75+
.background(Color(.systemBackground).opacity(0.9))
76+
.padding(-2) // Wasn't covering the chart well
77+
}
78+
}
79+
}
80+
.animation(.spring, value: selectedMetric)
81+
.animation(.spring, value: selectedChartType)
82+
}
83+
84+
private var footerView: some View {
85+
MetricsOverviewTabView(
86+
data: viewModel.isFirstLoad ? viewModel.placeholderTabViewData : viewModel.tabViewData,
87+
selectedMetric: $selectedMetric
88+
)
89+
}
90+
91+
private var mockChartData: ChartData {
92+
ChartData.mock(metric: .views, granularity: dateRange.dateInterval.preferredGranularity, range: dateRange)
93+
}
94+
95+
// MARK: - Header View
96+
97+
private var moreMenu: some View {
98+
Menu {
99+
moreMenuContent
100+
} label: {
101+
Image(systemName: "ellipsis")
102+
.font(.body)
103+
.foregroundColor(.secondary)
104+
.frame(width: 50, height: 50)
105+
}
106+
.tint(Color.primary)
107+
.sheet(isPresented: $isShowingRawData) {
108+
NavigationStack {
109+
ChartDataListView(
110+
chartDataDict: viewModel.chartData,
111+
selectedMetric: selectedMetric,
112+
dateRanges: dateRange
113+
)
114+
}
115+
}
116+
}
117+
118+
@ViewBuilder
119+
private var moreMenuContent: some View {
120+
Section {
121+
ControlGroup {
122+
ForEach(ChartType.allCases, id: \.self) { type in
123+
Button {
124+
selectedChartType = type
125+
} label: {
126+
Label(type.localizedTitle, systemImage: type.systemImage)
127+
}
128+
}
129+
}
130+
}
131+
Section {
132+
Button {
133+
// Not implemented
134+
} label: {
135+
Label(Strings.Buttons.share, systemImage: "square.and.arrow.up")
136+
}
137+
Button {
138+
isShowingRawData = true
139+
} label: {
140+
Label(Strings.Chart.showData, systemImage: "tablecells")
141+
}
142+
}
143+
}
144+
145+
// MARK: - Chart View
146+
147+
@ViewBuilder
148+
private func mainChartView(metric: SiteMetric, data: ChartData) -> some View {
149+
VStack(alignment: .leading, spacing: 8) {
150+
// Showing currently selected (not loaded period) by design
151+
ChartLegendView(
152+
metric: metric,
153+
currentPeriod: dateRange.dateInterval,
154+
previousPeriod: dateRange.effectiveComparisonInterval
155+
)
156+
.unredacted()
157+
.padding(.bottom, 6)
158+
.padding(.trailing, 20)
159+
160+
ChartValuesSummaryView(
161+
trend: TrendViewModel.make(data, context: .regular),
162+
style: metrics.count > 1 ? .compact : .standard
163+
)
164+
165+
chartContentView(data: data)
166+
.frame(height: chartHeight)
167+
.opacity(viewModel.isFirstLoad ? 0.33 : 1)
168+
.transition(.push(from: .trailing).combined(with: .opacity).combined(with: .scale))
169+
}
170+
}
171+
172+
@ViewBuilder
173+
private func chartContentView(data: ChartData) -> some View {
174+
switch selectedChartType {
175+
case .line:
176+
LineChartView(data: data)
177+
case .columns:
178+
BarChartView(data: data)
179+
}
180+
}
181+
}
182+
183+
private enum ChartType: String, CaseIterable {
184+
case line
185+
case columns
186+
187+
var localizedTitle: String {
188+
switch self {
189+
case .line: Strings.Chart.lineChart
190+
case .columns: Strings.Chart.barChart
191+
}
192+
}
193+
194+
var systemImage: String {
195+
switch self {
196+
case .line: "chart.line.uptrend.xyaxis"
197+
case .columns: "chart.bar"
198+
}
199+
}
200+
}
201+
202+
// MARK: - Preview
203+
204+
#Preview {
205+
ScrollView {
206+
VStack(spacing: 20) {
207+
ChartCard(
208+
metrics: [.views, .visitors, .likes, .comments],
209+
dateRange: Calendar.demo.makeDateRange(for: .last7Days),
210+
service: MockStatsService()
211+
)
212+
.cardStyle()
213+
214+
ChartCard(
215+
metrics: [.timeOnSite, .bounceRate],
216+
dateRange: Calendar.demo.makeDateRange(for: .last30Days),
217+
service: MockStatsService()
218+
)
219+
.cardStyle()
220+
}
221+
.padding(.vertical)
222+
}
223+
.background(Color(.systemGroupedBackground))
224+
}

0 commit comments

Comments
 (0)