Files
links/mobile/Heygo/Views/LinkListView.swift
T
2025-08-18 21:20:14 +10:00

526 lines
21 KiB
Swift

import SwiftUI
import UIKit
struct LinkListView: View {
@EnvironmentObject var linkViewModel: LinkViewModel
@Environment(\.managedObjectContext) private var viewContext
@State private var showingAddLink = false
@State private var showingFilterSheet = false
@State private var selectedLink: Link?
@State private var showingMarkdownContent = false
@State private var markdownLink: Link?
@State private var showingTemplateParameters = false
@State private var templateLink: Link?
@State private var templateParameters: [String: String] = [:]
var body: some View {
NavigationView {
VStack {
// Search bar
SearchBar(text: $linkViewModel.searchText,
onCustomContentTap: { link in
markdownLink = link
showingMarkdownContent = true
},
onTemplateParameterInput: { link in
templateLink = link
templateParameters = [:]
showingTemplateParameters = true
})
.environmentObject(linkViewModel)
.padding(.horizontal)
// Filter and sort bar
HStack {
Button(action: { showingFilterSheet = true }) {
HStack {
Image(systemName: "line.horizontal.3.decrease.circle")
Text("filter")
if linkViewModel.selectedTag != nil { Text("•").foregroundColor(.blue) }
}
}
Spacer()
// Sorting controls
HStack(spacing: 8) {
Button(action: { linkViewModel.sortAscending = true }) {
Image(systemName: "arrow.up")
.foregroundColor(linkViewModel.sortAscending ? .blue : .gray)
}
Button(action: { linkViewModel.sortAscending = false }) {
Image(systemName: "arrow.down")
.foregroundColor(!linkViewModel.sortAscending ? .blue : .gray)
}
Menu {
ForEach(LinkViewModel.SortOption.allCases, id: \.self) { option in
Button(action: { linkViewModel.selectedSortOption = option }) {
HStack {
Text(option.displayName)
if linkViewModel.selectedSortOption == option { Image(systemName: "checkmark") }
}
}
}
} label: {
Text(linkViewModel.selectedSortOption.displayName)
.foregroundColor(.blue)
}
}
}
.padding(.horizontal)
.padding(.vertical, 8)
// Count indicator (shows filtered count and total)
if !linkViewModel.isLoading {
HStack(spacing: 6) {
let filtered = linkViewModel.filteredLinks.count
let total = linkViewModel.links.count
Text("\(filtered)")
.font(.caption)
.fontWeight(.semibold)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.secondary.opacity(0.15))
.foregroundColor(.secondary)
.cornerRadius(4)
if filtered != total && total > 0 {
Text("of \(total)")
.font(.caption2)
.foregroundColor(.secondary)
}
Spacer()
}
.padding(.horizontal)
.padding(.bottom, 2)
}
// Links list
if linkViewModel.isLoading {
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
} else if linkViewModel.filteredLinks.isEmpty {
VStack {
Image(systemName: "link.badge.plus").font(.system(size: 60)).foregroundColor(.secondary)
Text("no_links_found").font(.title2).foregroundColor(.secondary)
Text("tap_plus_to_add_link").font(.caption).foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
List {
ForEach(linkViewModel.filteredLinks) { link in
LinkRowView(link: link) { customLink in
markdownLink = customLink
showingMarkdownContent = true
}
.onTapGesture { selectedLink = link }
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button("delete", role: .destructive) { linkViewModel.deleteLink(link) }
Button("edit") { selectedLink = link; showingAddLink = true }.tint(.blue)
}
}
}
.refreshable { linkViewModel.fetchLinks() }
}
}
.navigationTitle("")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
HStack { Image(systemName: "dog.fill").foregroundColor(.black); Text("Heygo").font(.headline).foregroundColor(.primary) }
}
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: { selectedLink = nil; showingAddLink = true }) { Image(systemName: "plus") }
}
}
.sheet(isPresented: $showingAddLink) { LinkFormView(link: selectedLink).environmentObject(linkViewModel) }
.sheet(isPresented: $showingFilterSheet) { FilterView().environmentObject(linkViewModel) }
.sheet(item: $selectedLink) { link in LinkDetailView(link: link).environmentObject(linkViewModel) }
.sheet(isPresented: $showingMarkdownContent) { if let markdownLink = markdownLink { MarkdownContentView(link: markdownLink) } }
.sheet(isPresented: $showingTemplateParameters) {
if let templateLink = templateLink {
TemplateParameterView(
link: templateLink,
parameters: $templateParameters,
onOpen: { params in
linkViewModel.openLink(templateLink, with: params)
showingTemplateParameters = false
}
)
}
}
}
.onAppear { if linkViewModel.links.isEmpty { linkViewModel.fetchLinks() } }
.alert("Error", isPresented: .constant(linkViewModel.errorMessage != nil)) { Button("OK") { linkViewModel.errorMessage = nil } } message: { Text(linkViewModel.errorMessage ?? "") }
}
}
struct LinkRowView: View {
let link: Link
let onCustomContentTap: (Link) -> Void
@EnvironmentObject var linkViewModel: LinkViewModel
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
// Alias
HStack {
Text(link.alias)
.font(.headline)
.foregroundColor(.primary)
Spacer()
// Link type badge
Text(link.linkTypeEnum.displayName)
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background(linkTypeColor(link.linkTypeEnum))
.foregroundColor(.white)
.cornerRadius(4)
}
// URL or description
if let url = link.originalURL, !url.isEmpty {
Text(url)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
} else if let text = link.text, !text.isEmpty {
Text("custom_content - \(link.alias)")
.font(.caption)
.foregroundColor(.secondary)
.italic()
}
// Description
if let description = link.linkDescription, !description.isEmpty {
Text(description)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(2)
}
// Tags and stats
HStack {
// Tags
if !link.sortedTags.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 4) {
ForEach(link.sortedTags, id: \.id) { tag in
Text(tag.name)
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.blue.opacity(0.2))
.foregroundColor(.blue)
.cornerRadius(4)
}
}
}
}
Spacer()
// Click count
HStack(spacing: 2) {
Image(systemName: "hand.tap")
Text("\(link.clickCount)")
}
.font(.caption2)
.foregroundColor(.secondary)
}
}
// Action button
Button(action: {
switch link.linkTypeEnum {
case .custom:
// Record click for custom links and show markdown content
linkViewModel.recordClick(for: link)
onCustomContentTap(link)
case .link, .template:
// Open URL for regular links and templates
linkViewModel.openLink(link)
}
}) {
Image(systemName: "arrow.up.right.circle.fill")
.foregroundColor(.blue)
.font(.title2)
}
.buttonStyle(PlainButtonStyle())
}
.contentShape(Rectangle())
.padding(.vertical, 4)
}
private func linkTypeColor(_ linkType: LinkType) -> Color {
switch linkType {
case .link:
return .blue
case .custom:
return .green
case .template:
return .orange
}
}
}
struct SearchBar: View {
@Binding var text: String
@State private var isEditing = false
@EnvironmentObject var linkViewModel: LinkViewModel
let onCustomContentTap: (Link) -> Void
let onTemplateParameterInput: (Link) -> Void
var body: some View {
HStack {
HStack(spacing: 0) {
// Blue prefix segment
Text("go/")
.font(.subheadline.monospaced())
.fontWeight(.semibold)
.foregroundColor(.white)
.padding(.horizontal, 8)
.padding(.vertical, 6)
.background(Color(.systemBlue))
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.accessibilityLabel("go prefix")
// Input field
TextField(NSLocalizedString("quick_go_placeholder", comment: "Quick go placeholder"), text: Binding(
get: { text },
set: { newValue in
let lowered = newValue.lowercased()
let filtered = lowered.filter { ($0 >= "a" && $0 <= "z") || ($0 >= "0" && $0 <= "9") || $0 == "-" }
if filtered != text { text = filtered } else { text = lowered }
}
), onCommit: {
triggerGo()
})
.textInputAutocapitalization(.never)
.autocorrectionDisabled(true)
.keyboardType(.default)
.submitLabel(.go)
.padding(.vertical, 6)
.padding(.horizontal, 8)
}
.background(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.fill(Color(.systemGray6))
)
.overlay(
RoundedRectangle(cornerRadius: 8, style: .continuous)
.stroke(Color(.systemGray4), lineWidth: 1)
)
.onTapGesture { self.isEditing = true }
if isEditing {
Button(action: {
self.isEditing = false
self.text = ""
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}) {
Text("cancel")
}
.padding(.trailing, 10)
.transition(.move(edge: .trailing))
.animation(.default, value: isEditing)
}
}
}
private func triggerGo() {
// Behavior: if first filtered link exists open it; else treat text as URL attempt
if let first = linkViewModel.filteredLinks.first {
switch first.linkTypeEnum {
case .custom:
// Show markdown content for custom links
linkViewModel.recordClick(for: first)
onCustomContentTap(first)
text = "" // Clear search after navigation
case .template:
// Show template parameter input for template links
onTemplateParameterInput(first)
text = "" // Clear search after navigation
case .link:
// Open URL directly for regular links
linkViewModel.openLink(first)
text = "" // Clear search after navigation
}
} else if !text.isEmpty {
// Attempt to open as raw URL alias? Optionally do nothing.
linkViewModel.performQuickSearch(text)
}
}
}
struct FilterView: View {
@EnvironmentObject var linkViewModel: LinkViewModel
@Environment(\.presentationMode) var presentationMode
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Tag.name, ascending: true)],
animation: .default)
private var tags: FetchedResults<Tag>
var body: some View {
NavigationView {
List {
Section("filters") {
HStack {
Text("selected_tag")
Spacer()
if let selectedTag = linkViewModel.selectedTag {
Text(selectedTag.name)
.foregroundColor(.secondary)
} else {
Text("all_tags")
.foregroundColor(.secondary)
}
}
.onTapGesture {
linkViewModel.selectedTag = nil
}
}
Section("tags") {
ForEach(tags) { tag in
HStack {
Text(tag.name)
Spacer()
Text("\(tag.linkCount)")
.foregroundColor(.secondary)
if linkViewModel.selectedTag == tag {
Image(systemName: "checkmark")
.foregroundColor(.blue)
}
}
.onTapGesture {
linkViewModel.selectedTag = tag
presentationMode.wrappedValue.dismiss()
}
}
}
}
.navigationTitle("filter_links")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button("done") {
presentationMode.wrappedValue.dismiss()
}
}
}
}
}
}
#Preview {
LinkListView()
.environmentObject(LinkViewModel())
.environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
}
struct MarkdownContentView: View {
let link: Link
@Environment(\.presentationMode) var presentationMode
@EnvironmentObject var linkViewModel: LinkViewModel
@State private var showingEditLink = false
@State private var showingShareSheet = false
private var shareItems: [Any] {
var items: [Any] = []
// Add the alias as title
items.append("Custom Content: \(link.alias)")
// Add the markdown content if available
if let content = link.text, !content.isEmpty {
items.append(content)
}
return items
}
var body: some View {
NavigationView {
VStack(alignment: .leading, spacing: 12) {
// Alias label
HStack {
Text(link.alias)
.font(.caption)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.blue.opacity(0.2))
.foregroundColor(.blue)
.cornerRadius(6)
Spacer()
}
.padding(.horizontal)
// Markdown content
ScrollView {
if let content = link.text, !content.isEmpty {
Text(MarkdownRenderer.renderToAttributedString(content))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal)
.textSelection(.enabled)
} else {
VStack {
Image(systemName: "doc.text")
.font(.system(size: 50))
.foregroundColor(.secondary)
Text("No content available")
.font(.title3)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
}
.navigationTitle("Custom Content - \(link.alias)")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("Edit") {
showingEditLink = true
}
}
ToolbarItem(placement: .navigationBarTrailing) {
HStack(spacing: 16) {
Button(action: {
showingShareSheet = true
}) {
Image(systemName: "square.and.arrow.up")
}
Button("Done") {
presentationMode.wrappedValue.dismiss()
}
}
}
}
.sheet(isPresented: $showingEditLink) {
LinkFormView(link: link)
.environmentObject(linkViewModel)
}
.sheet(isPresented: $showingShareSheet) {
ShareSheet(activityItems: shareItems)
}
}
}
}
struct ShareSheet: UIViewControllerRepresentable {
let activityItems: [Any]
func makeUIViewController(context: Context) -> UIActivityViewController {
let controller = UIActivityViewController(activityItems: activityItems, applicationActivities: nil)
return controller
}
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {
// No updates needed
}
}