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

470 lines
15 KiB
Swift

import SwiftUI
import Charts
struct LinkDetailView: View {
let link: Link
@EnvironmentObject var linkViewModel: LinkViewModel
@Environment(\.presentationMode) var presentationMode
@State private var selectedTimeRange: TimeRange = .threeMonths
@State private var showingEdit = false
@State private var templateParameters: [String: String] = [:]
@State private var showingTemplateDialog = false
enum TimeRange: String, CaseIterable {
case threeMonths = "3m"
case sixMonths = "6m"
case oneYear = "1y"
case allTime = "all"
var displayName: String {
switch self {
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 .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
}
}
}
var filteredClickLogs: [ClickLog] {
let logs = link.sortedClickLogs
guard let dateRange = selectedTimeRange.dateRange else {
return logs
}
return logs.filter { log in
dateRange.contains(log.clickedAt)
}
}
var chartData: [ChartDataPoint] {
let calendar = Calendar.current
let logs = filteredClickLogs
guard !logs.isEmpty else { return [] }
// Determine the grouping based on time range
let component: Calendar.Component
let dateFormat: String
switch selectedTimeRange {
case .threeMonths, .sixMonths:
component = .day
dateFormat = "MMM d"
case .oneYear:
component = .weekOfYear
dateFormat = "MMM d"
case .allTime:
component = .month
dateFormat = "MMM yyyy"
}
// Group clicks by the component
let groupedLogs = Dictionary(grouping: logs) { log in
calendar.dateInterval(of: component, for: log.clickedAt)?.start ?? log.clickedAt
}
// Create chart data points
let formatter = DateFormatter()
formatter.dateFormat = dateFormat
return groupedLogs.map { date, logs in
ChartDataPoint(
date: date,
count: logs.count,
label: formatter.string(from: date)
)
}.sorted { $0.date < $1.date }
}
var body: some View {
NavigationView {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
headerSection
tagsSection
analyticsSection
customContentSection
recentClicksSection
changeHistorySection
}
.padding()
}
.navigationTitle("link_details")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("close") {
presentationMode.wrappedValue.dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
toolbarMenu
}
}
}
.sheet(isPresented: $showingEdit) {
LinkFormView(link: link)
.environmentObject(linkViewModel)
}
.sheet(isPresented: $showingTemplateDialog) {
TemplateParameterView(
link: link,
parameters: $templateParameters,
onOpen: { params in
linkViewModel.openLink(link, with: params)
presentationMode.wrappedValue.dismiss()
}
)
}
}
private var headerSection: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text(link.alias)
.font(.largeTitle)
.fontWeight(.bold)
Spacer()
Text(link.linkTypeEnum.displayName)
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(linkTypeColor(link.linkTypeEnum))
.foregroundColor(.white)
.cornerRadius(6)
}
if let url = link.originalURL {
Text(url)
.font(.body)
.foregroundColor(.blue)
.lineLimit(nil)
}
if let description = link.linkDescription {
Text(description)
.font(.body)
.foregroundColor(.secondary)
}
}
.padding()
.background(Color(.systemGray6))
.cornerRadius(12)
}
private var tagsSection: some View {
Group {
if !link.sortedTags.isEmpty {
VStack(alignment: .leading) {
Text("tags")
.font(.headline)
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(link.sortedTags, id: \.id) { tag in
Text(tag.name)
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.blue.opacity(0.2))
.foregroundColor(.blue)
.cornerRadius(6)
}
}
.padding(.horizontal)
}
}
}
}
}
private var analyticsSection: some View {
VStack(alignment: .leading, spacing: 12) {
HStack {
Text("analytics")
.font(.headline)
Spacer()
Picker("time_range", selection: $selectedTimeRange) {
ForEach(TimeRange.allCases, id: \.self) { range in
Text(range.displayName).tag(range)
}
}
.pickerStyle(SegmentedPickerStyle())
.frame(maxWidth: 200)
}
analyticsStatsCards
analyticsChart
}
}
private var analyticsStatsCards: some View {
HStack(spacing: 16) {
StatCard(
title: "total_clicks",
value: "\(link.clickCount)",
icon: "hand.tap"
)
StatCard(
title: "clicks_in_period",
value: "\(filteredClickLogs.count)",
icon: "chart.bar"
)
}
}
private var analyticsChart: some View {
Group {
if !chartData.isEmpty {
Chart(chartData) { dataPoint in
BarMark(
x: .value("Date", dataPoint.date),
y: .value("Clicks", dataPoint.count)
)
.foregroundStyle(.blue)
}
.frame(height: 200)
.chartXAxisLabel("Date")
.chartYAxisLabel("Clicks")
} else {
Text("no_data_for_period")
.font(.body)
.foregroundColor(.secondary)
.frame(height: 200)
.frame(maxWidth: .infinity)
.background(Color(.systemGray6))
.cornerRadius(8)
}
}
}
private var customContentSection: some View {
Group {
if link.linkTypeEnum == .custom, let text = link.text {
VStack(alignment: .leading) {
Text("content_preview")
.font(.headline)
MarkdownView(text: text)
.frame(maxHeight: 300)
.background(Color(.systemGray6))
.cornerRadius(8)
}
}
}
}
private var recentClicksSection: some View {
Group {
if !filteredClickLogs.isEmpty {
VStack(alignment: .leading) {
Text("recent_clicks")
.font(.headline)
ForEach(filteredClickLogs.prefix(10), id: \.id) { clickLog in
HStack {
Image(systemName: "hand.tap")
.foregroundColor(.blue)
Text(clickLog.clickedAt, format: .dateTime)
.font(.body)
Spacer()
}
.padding(.vertical, 2)
}
}
}
}
}
private var changeHistorySection: some View {
Group {
if ((link.changeLogs?.allObjects.isEmpty) == nil) {
VStack(alignment: .leading) {
Text("change_history")
.font(.headline)
let changeLogs = (link.changeLogs?.allObjects as? [ChangeLog] ?? [])
.sorted { $0.changedAt > $1.changedAt }
ForEach(changeLogs.prefix(10), id: \.id) { changeLog in
HStack {
Image(systemName: changeLog.changeType == "CREATE" ? "plus.circle" : "pencil.circle")
.foregroundColor(changeLog.changeType == "CREATE" ? .green : .orange)
VStack(alignment: .leading) {
Text(changeLog.changeDescription)
.font(.body)
Text(changeLog.changedAt, format: .dateTime)
.font(.caption)
.foregroundColor(.secondary)
}
Spacer()
}
.padding(.vertical, 2)
}
}
}
}
}
private var toolbarMenu: some View {
Menu {
Button("edit") {
showingEdit = true
}
if link.isTemplate {
Button("open_with_parameters") {
showingTemplateDialog = true
}
} else {
Button("open_link") {
linkViewModel.openLink(link)
}
}
Button("delete", role: .destructive) {
linkViewModel.deleteLink(link)
presentationMode.wrappedValue.dismiss()
}
} 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 StatCard: View {
let title: String
let value: String
let icon: String
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: icon)
.foregroundColor(.blue)
Text(title)
.font(.caption)
.foregroundColor(.secondary)
}
Text(value)
.font(.title2)
.fontWeight(.semibold)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.systemGray6))
.cornerRadius(8)
}
}
struct ChartDataPoint: Identifiable {
let id = UUID()
let date: Date
let count: Int
let label: String
}
struct TemplateParameterView: View {
let link: Link
@Binding var parameters: [String: String]
let onOpen: ([String: String]) -> Void
@Environment(\.presentationMode) var presentationMode
@State private var extractedParams: [TemplateParameter] = []
var body: some View {
NavigationView {
Form {
Section("template_parameters") {
ForEach(extractedParams, id: \.name) { param in
VStack(alignment: .leading) {
Text(param.name)
.font(.headline)
TextField(param.defaultValue ?? "enter_value", text: Binding(
get: { parameters[param.name] ?? param.defaultValue ?? "" },
set: { parameters[param.name] = $0 }
))
.textFieldStyle(RoundedBorderTextFieldStyle())
}
}
}
}
.navigationTitle("enter_parameters")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("cancel") {
presentationMode.wrappedValue.dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("open") {
onOpen(parameters)
}
}
}
}
.onAppear {
extractTemplateParameters()
}
}
private func extractTemplateParameters() {
guard let url = link.originalURL else { return }
extractedParams = URLTemplateProcessor.extractParameters(from: url)
}
}
#Preview {
LinkDetailView(link: PersistenceController.preview.container.viewContext.registeredObjects.first { $0 is Link } as! Link)
.environmentObject(LinkViewModel())
}