Files
links/mobile/Heygo/Views/AnalyticsView.swift
T
2025-08-16 11:13:00 +10:00

477 lines
15 KiB
Swift

import SwiftUI
import Charts
struct AnalyticsView: View {
@EnvironmentObject var linkViewModel: LinkViewModel
@State private var selectedTimeRange: TimeRange = .oneMonth
@State private var selectedMetric: Metric = .clicks
@State private var showingExport = false
enum TimeRange: String, CaseIterable {
case oneWeek = "1w"
case oneMonth = "1m"
case threeMonths = "3m"
case sixMonths = "6m"
case oneYear = "1y"
case allTime = "all"
var displayName: String {
switch self {
case .oneWeek:
return NSLocalizedString("1_week", comment: "1 week")
case .oneMonth:
return NSLocalizedString("1_month", comment: "1 month")
case .threeMonths:
return NSLocalizedString("3_months", comment: "3 months")
case .sixMonths:
return NSLocalizedString("6_months", comment: "6 months")
case .oneYear:
return NSLocalizedString("1_year", comment: "1 year")
case .allTime:
return NSLocalizedString("all_time", comment: "All time")
}
}
var dateRange: DateInterval? {
let calendar = Calendar.current
let endDate = Date()
switch self {
case .oneWeek:
let startDate = calendar.date(byAdding: .weekOfYear, value: -1, to: endDate)!
return DateInterval(start: startDate, end: endDate)
case .oneMonth:
let startDate = calendar.date(byAdding: .month, value: -1, to: endDate)!
return DateInterval(start: startDate, end: endDate)
case .threeMonths:
let startDate = calendar.date(byAdding: .month, value: -3, to: endDate)!
return DateInterval(start: startDate, end: endDate)
case .sixMonths:
let startDate = calendar.date(byAdding: .month, value: -6, to: endDate)!
return DateInterval(start: startDate, end: endDate)
case .oneYear:
let startDate = calendar.date(byAdding: .year, value: -1, to: endDate)!
return DateInterval(start: startDate, end: endDate)
case .allTime:
return nil
}
}
}
enum Metric: String, CaseIterable {
case clicks = "clicks"
case links = "links"
case topLinks = "top_links"
var displayName: String {
switch self {
case .clicks:
return NSLocalizedString("clicks_over_time", comment: "Clicks over time")
case .links:
return NSLocalizedString("links_created", comment: "Links created")
case .topLinks:
return NSLocalizedString("top_links", comment: "Top links")
}
}
}
var analyticsData: AnalyticsData {
return AnalyticsCalculator.calculateAnalytics(
for: linkViewModel.links,
timeRange: selectedTimeRange,
metric: selectedMetric
)
}
var body: some View {
NavigationView {
ScrollView {
VStack(alignment: .leading, spacing: 20) {
timeRangePicker
overviewCards
metricSelector
mainChart
topLinksSection
linkTypeDistributionSection
recentActivitySection
}
.padding()
}
.navigationTitle("analytics")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
analyticsToolbar
}
}
}
.sheet(isPresented: $showingExport) {
ExportView(analyticsData: analyticsData)
}
}
private var timeRangePicker: some View {
VStack(alignment: .leading) {
Text("time_range")
.font(.headline)
Picker("time_range", selection: $selectedTimeRange) {
ForEach(TimeRange.allCases, id: \.self) { range in
Text(range.displayName).tag(range)
}
}
.pickerStyle(SegmentedPickerStyle())
}
}
private var overviewCards: some View {
LazyVGrid(columns: [
GridItem(.flexible()),
GridItem(.flexible())
], spacing: 16) {
OverviewCard(
title: "total_links",
value: "\(analyticsData.totalLinks)",
change: analyticsData.linksChange,
icon: "link"
)
OverviewCard(
title: "total_clicks",
value: "\(analyticsData.totalClicks)",
change: analyticsData.clicksChange,
icon: "hand.tap"
)
OverviewCard(
title: "avg_clicks_per_link",
value: String(format: "%.1f", analyticsData.averageClicksPerLink),
change: analyticsData.avgClicksChange,
icon: "chart.bar"
)
OverviewCard(
title: "active_links",
value: "\(analyticsData.activeLinks)",
change: analyticsData.activeLinksChange,
icon: "bolt"
)
}
}
private var metricSelector: some View {
VStack(alignment: .leading) {
Text("metric")
.font(.headline)
Picker("metric", selection: $selectedMetric) {
ForEach(Metric.allCases, id: \.self) { metric in
Text(metric.displayName).tag(metric)
}
}
.pickerStyle(SegmentedPickerStyle())
}
}
private var mainChart: some View {
VStack(alignment: .leading) {
Text(selectedMetric.displayName)
.font(.headline)
if analyticsData.chartData.isEmpty {
Text("no_data_available")
.font(.body)
.foregroundColor(.secondary)
.frame(height: 200)
.frame(maxWidth: .infinity)
.background(Color(.systemGray6))
.cornerRadius(12)
} else {
Chart(analyticsData.chartData) { dataPoint in
switch selectedMetric {
case .clicks:
LineMark(
x: .value("Date", dataPoint.date),
y: .value("Clicks", dataPoint.count)
)
.foregroundStyle(.blue)
case .links:
BarMark(
x: .value("Date", dataPoint.date),
y: .value("Links", dataPoint.count)
)
.foregroundStyle(.green)
case .topLinks:
BarMark(
x: .value("Link", dataPoint.label),
y: .value("Clicks", dataPoint.count)
)
.foregroundStyle(.orange)
}
}
.frame(height: 200)
.chartXAxisLabel(selectedMetric == .topLinks ? "Links" : "Date")
.chartYAxisLabel(selectedMetric == .links ? "Links Created" : "Clicks")
}
}
}
private var topLinksSection: some View {
VStack(alignment: .leading) {
Text("top_performing_links")
.font(.headline)
ForEach(analyticsData.topLinks, id: \.link.id) { linkData in
TopLinkRow(linkData: linkData)
}
}
}
private var linkTypeDistributionSection: some View {
VStack(alignment: .leading) {
Text("link_type_distribution")
.font(.headline)
if !analyticsData.linkTypeDistribution.isEmpty {
Chart(analyticsData.linkTypeDistribution, id: \.type) { data in
SectorMark(
angle: .value("Count", data.count),
innerRadius: .ratio(0.5),
angularInset: 2
)
.foregroundStyle(linkTypeColor(data.type))
}
.frame(height: 200)
}
LazyVGrid(columns: [
GridItem(.flexible()),
GridItem(.flexible()),
GridItem(.flexible())
], spacing: 12) {
ForEach(analyticsData.linkTypeDistribution, id: \.type) { data in
VStack {
Text(data.type.displayName)
.font(.caption)
.foregroundColor(.secondary)
Text("\(data.count)")
.font(.headline)
.foregroundColor(linkTypeColor(data.type))
}
.frame(maxWidth: .infinity)
.padding()
.background(Color(.systemGray6))
.cornerRadius(8)
}
}
}
}
private var recentActivitySection: some View {
VStack(alignment: .leading) {
Text("recent_activity")
.font(.headline)
ForEach(analyticsData.recentActivity.prefix(10), id: \.id) { activity in
RecentActivityRow(activity: activity)
}
}
}
private var analyticsToolbar: some View {
Menu {
Button("export_data") {
showingExport = true
}
Button("refresh_data") {
linkViewModel.fetchLinks()
}
} label: {
Image(systemName: "ellipsis.circle")
}
}
private func linkTypeColor(_ linkType: LinkType) -> Color {
switch linkType {
case .link:
return .blue
case .custom:
return .green
case .template:
return .orange
}
}
}
struct OverviewCard: View {
let title: String
let value: String
let change: Double?
let icon: String
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: icon)
.foregroundColor(.blue)
Text(title)
.font(.caption)
.foregroundColor(.secondary)
Spacer()
}
Text(value)
.font(.title2)
.fontWeight(.semibold)
if let change = change {
HStack {
Image(systemName: change >= 0 ? "arrow.up.right" : "arrow.down.right")
.font(.caption2)
Text(String(format: "%.1f%%", abs(change)))
.font(.caption2)
}
.foregroundColor(change >= 0 ? .green : .red)
}
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.systemGray6))
.cornerRadius(12)
}
}
struct TopLinkRow: View {
let linkData: TopLinkData
var body: some View {
HStack {
VStack(alignment: .leading) {
Text(linkData.link.alias)
.font(.body)
.fontWeight(.medium)
if let url = linkData.link.originalURL {
Text(url)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
}
}
Spacer()
VStack(alignment: .trailing) {
Text("\(linkData.clicks)")
.font(.headline)
.foregroundColor(.blue)
Text("clicks")
.font(.caption)
.foregroundColor(.secondary)
}
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(8)
}
}
struct RecentActivityRow: View {
let activity: RecentActivity
var body: some View {
HStack {
Image(systemName: activity.type.icon)
.foregroundColor(activity.type.color)
.frame(width: 24)
VStack(alignment: .leading) {
Text(activity.description)
.font(.body)
Text(activity.timestamp, format: .relative(presentation: .named))
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
.padding(.vertical, 4)
}
}
struct ExportView: View {
let analyticsData: AnalyticsData
@Environment(\.presentationMode) var presentationMode
@State private var exportFormat: ExportFormat = .json
@State private var includeChartData = true
@State private var includeTopLinks = true
@State private var isExporting = false
enum ExportFormat: String, CaseIterable {
case json = "JSON"
case csv = "CSV"
var displayName: String {
return rawValue
}
}
var body: some View {
NavigationView {
Form {
Section("export_options") {
Picker("format", selection: $exportFormat) {
ForEach(ExportFormat.allCases, id: \.self) { format in
Text(format.displayName).tag(format)
}
}
Toggle("include_chart_data", isOn: $includeChartData)
Toggle("include_top_links", isOn: $includeTopLinks)
}
Section("preview") {
Text("total_links: \(analyticsData.totalLinks)")
Text("total_clicks: \(analyticsData.totalClicks)")
Text("chart_data_points: \(analyticsData.chartData.count)")
Text("top_links: \(analyticsData.topLinks.count)")
}
}
.navigationTitle("export_analytics")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("cancel") {
presentationMode.wrappedValue.dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("export") {
exportData()
}
.disabled(isExporting)
}
}
}
}
private func exportData() {
isExporting = true
// Implementation would depend on the specific export requirements
// For now, we'll just simulate the export
DispatchQueue.main.asyncAfter(deadline: .now() + 1) {
isExporting = false
presentationMode.wrappedValue.dismiss()
}
}
}
#Preview {
AnalyticsView()
.environmentObject(LinkViewModel())
}