Files
links/mobile/Heygo/Views/LinkFormView.swift
T
2025-08-16 18:06:07 +10:00

515 lines
19 KiB
Swift

import SwiftUI
struct LinkFormView: View {
@EnvironmentObject var linkViewModel: LinkViewModel
@Environment(\.presentationMode) var presentationMode
@Environment(\.managedObjectContext) private var viewContext
let link: Link?
@State private var alias: String = ""
@State private var originalURL: String = ""
@State private var text: String = ""
@State private var linkDescription: String = ""
@State private var selectedLinkType: LinkType = .link
@State private var selectedTags: [Tag] = []
@State private var tagSearchText: String = ""
@State private var isValidatingAlias: Bool = false
@State private var aliasValidationMessage: String = ""
@State private var urlValidationMessage: String = ""
@State private var showingTagPicker: Bool = false
@State private var saveErrorMessage: String = ""
@State private var showingSaveError: Bool = false
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Tag.name, ascending: true)],
animation: .default)
private var allTags: FetchedResults<Tag>
var isEditing: Bool {
return link != nil
}
var filteredTags: [Tag] {
if tagSearchText.isEmpty {
return Array(allTags)
} else {
return allTags.filter { $0.name.localizedCaseInsensitiveContains(tagSearchText) }
}
}
var canSave: Bool {
return !alias.isEmpty &&
(selectedLinkType == .custom ? !text.isEmpty : !originalURL.isEmpty) &&
aliasValidationMessage.isEmpty &&
urlValidationMessage.isEmpty
}
var templateValidationView: some View {
Group {
if selectedLinkType == .template && !originalURL.isEmpty {
let isValidTemplate = URLTemplateProcessor.validateTemplate(originalURL)
let hasParameters = URLTemplateProcessor.hasParameters(originalURL)
if isValidTemplate && hasParameters {
// Valid template with parameters - show success with iOS blue
HStack {
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.blue)
Text("Valid template with parameters")
.font(.caption)
.foregroundColor(.blue)
}
} else if !hasParameters {
// No parameters detected - show warning
HStack {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundColor(.orange)
Text("Template should contain parameters like {query} or {query,default=value}")
.font(.caption)
.foregroundColor(.orange)
}
} else {
// Invalid template - show error
HStack {
Image(systemName: "xmark.circle.fill")
.foregroundColor(.red)
Text("Invalid template syntax. Use {parameter} or {parameter,default=value}")
.font(.caption)
.foregroundColor(.red)
}
}
}
}
}
var body: some View {
NavigationView {
Form {
Section("basic_information") {
// Alias field
VStack(alignment: .leading) {
TextField("alias", text: $alias)
.autocapitalization(.none)
.autocorrectionDisabled()
.onChange(of: alias) { _, newValue in
validateAlias(newValue)
}
if !aliasValidationMessage.isEmpty {
Text(aliasValidationMessage)
.font(.caption)
.foregroundColor(.red)
}
}
// Link Type Picker
Picker("link_type", selection: $selectedLinkType) {
ForEach(LinkType.allCases, id: \.self) { linkType in
Text(linkType.displayName).tag(linkType)
}
}
.pickerStyle(SegmentedPickerStyle())
.onChange(of: selectedLinkType) { _, newValue in
// Re-validate URL when link type changes
if newValue != .custom {
validateURL(originalURL)
} else {
urlValidationMessage = ""
}
}
// URL or Text field based on type
if selectedLinkType == .custom {
VStack(alignment: .leading) {
Text("markdown_content")
.font(.caption)
.foregroundColor(.secondary)
TextEditor(text: $text)
.frame(minHeight: 100)
}
} else {
VStack(alignment: .leading) {
TextField("original_url", text: $originalURL)
.autocapitalization(.none)
.autocorrectionDisabled()
.keyboardType(.URL)
.textFieldStyle(RoundedBorderTextFieldStyle())
.overlay(
RoundedRectangle(cornerRadius: 8)
.stroke(getBorderColor(), lineWidth: 2)
)
.onChange(of: originalURL) { _, newValue in
validateURL(newValue)
}
if !urlValidationMessage.isEmpty {
HStack {
Image(systemName: "exclamationmark.triangle")
.foregroundColor(.orange)
Text(urlValidationMessage)
.font(.caption)
.foregroundColor(.orange)
}
}
if selectedLinkType == .template {
VStack(alignment: .leading, spacing: 8) {
// Template help text
VStack(alignment: .leading, spacing: 4) {
Text("Template type allows you to create dynamic URLs with parameters. Use {query, default=value} syntax in the URL.")
.font(.caption)
.foregroundColor(.secondary)
Text("Example: https://google.com/search?q={query, default=hello}")
.font(.caption)
.foregroundColor(.secondary)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.gray.opacity(0.1))
.cornerRadius(4)
}
// Template validation status
if !originalURL.isEmpty {
templateValidationView
}
}
}
}
}
// Description field
TextField("description_optional", text: $linkDescription)
}
Section("tags") {
// Selected tags
if !selectedTags.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(selectedTags, id: \.id) { tag in
TagChip(tag: tag, onRemove: {
selectedTags.removeAll { $0.id == tag.id }
})
}
}
.padding(.horizontal)
}
}
// Tag search and selection
HStack {
TextField("search_or_create_tag", text: $tagSearchText)
Button("add") {
addOrCreateTag()
}
.disabled(tagSearchText.isEmpty)
}
// Filtered tags list
ForEach(filteredTags.prefix(5), id: \.id) { tag in
HStack {
Text(tag.name)
Spacer()
if selectedTags.contains(where: { $0.id == tag.id }) {
Image(systemName: "checkmark")
.foregroundColor(.blue)
}
}
.onTapGesture {
toggleTag(tag)
}
}
}
if isEditing {
Section("actions") {
Button("delete_link", role: .destructive) {
if let link = link {
linkViewModel.deleteLink(link)
presentationMode.wrappedValue.dismiss()
}
}
}
}
}
.navigationTitle(isEditing ? "edit_link" : "add_link")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .navigationBarLeading) {
Button("cancel") {
presentationMode.wrappedValue.dismiss()
}
}
ToolbarItem(placement: .navigationBarTrailing) {
Button("save") {
saveLink()
}
.disabled(!canSave)
}
}
}
.onAppear {
setupForm()
}
.alert("Error", isPresented: $showingSaveError) {
Button("OK") {
showingSaveError = false
saveErrorMessage = ""
}
} message: {
Text(saveErrorMessage)
}
}
private func setupForm() {
if let link = link {
alias = link.alias
originalURL = link.originalURL ?? ""
text = link.text ?? ""
linkDescription = link.linkDescription ?? ""
selectedLinkType = link.linkTypeEnum
selectedTags = link.sortedTags
// Validate existing data
validateAlias(alias)
validateURL(originalURL)
}
}
private func validateAlias(_ newAlias: String) {
isValidatingAlias = true
// Basic validation
if newAlias.isEmpty {
aliasValidationMessage = ""
isValidatingAlias = false
return
}
// Check format (alphanumeric and some special characters)
let allowedCharacters = CharacterSet.alphanumerics.union(CharacterSet(charactersIn: "-_"))
if newAlias.unicodeScalars.contains(where: { !allowedCharacters.contains($0) }) {
aliasValidationMessage = NSLocalizedString("alias_invalid_characters", comment: "Alias contains invalid characters")
isValidatingAlias = false
return
}
// Check if alias exists (excluding current link if editing)
if !isEditing || (isEditing && newAlias != link?.alias) {
if linkViewModel.aliasExists(newAlias) {
aliasValidationMessage = NSLocalizedString("alias_already_exists", comment: "Alias already exists")
} else {
aliasValidationMessage = ""
}
} else {
aliasValidationMessage = ""
}
isValidatingAlias = false
}
private func validateURL(_ url: String) {
// Don't validate empty URLs (allow empty for templates with placeholders)
if url.isEmpty {
urlValidationMessage = ""
return
}
// For templates, use URLTemplateProcessor for validation
if selectedLinkType == .template {
// Check if it's a valid template
if URLTemplateProcessor.validateTemplate(url) {
// Check if the base URL (without parameters) is valid
let urlWithoutPlaceholders = url.replacingOccurrences(of: #"\{[^}]+\}"#, with: "placeholder", options: .regularExpression)
if isValidURL(urlWithoutPlaceholders) {
urlValidationMessage = ""
return
} else {
urlValidationMessage = NSLocalizedString("invalid_base_url_format", comment: "Invalid base URL format")
return
}
} else {
urlValidationMessage = NSLocalizedString("invalid_template_format", comment: "Invalid template format")
return
}
}
// For regular links, validate the URL strictly
if selectedLinkType == .link {
if isValidURL(url) {
urlValidationMessage = ""
return
}
}
// If we reach here, the URL is invalid
urlValidationMessage = NSLocalizedString("invalid_url_format", comment: "Invalid URL format")
}
private func isValidURL(_ urlString: String) -> Bool {
// First check if it's a valid URL
guard let url = URL(string: urlString) else {
return false
}
// Check if it has a scheme
guard let scheme = url.scheme else {
return false
}
// Allow http, https, and other common schemes
let allowedSchemes = ["http", "https", "ftp", "mailto", "tel", "sms"]
if !allowedSchemes.contains(scheme.lowercased()) {
return false
}
// Check if it has a host for http/https URLs
if ["http", "https"].contains(scheme.lowercased()) {
guard let host = url.host, !host.isEmpty else {
return false
}
}
return true
}
private func getBorderColor() -> Color {
// Show error state if there's a validation message
if !urlValidationMessage.isEmpty {
return .orange
}
// For template links, show blue if valid template with parameters
if selectedLinkType == .template && !originalURL.isEmpty {
let isValidTemplate = URLTemplateProcessor.validateTemplate(originalURL)
let hasParameters = URLTemplateProcessor.hasParameters(originalURL)
if isValidTemplate && hasParameters {
return .blue
}
}
return .clear
}
private func toggleTag(_ tag: Tag) {
if selectedTags.contains(where: { $0.id == tag.id }) {
selectedTags.removeAll { $0.id == tag.id }
} else {
selectedTags.append(tag)
}
}
private func addOrCreateTag() {
let trimmedText = tagSearchText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedText.isEmpty else { return }
// Check if tag already exists
if let existingTag = allTags.first(where: { $0.name.lowercased() == trimmedText.lowercased() }) {
if !selectedTags.contains(where: { $0.id == existingTag.id }) {
selectedTags.append(existingTag)
}
} else {
// Create new tag
let newTag = Tag(context: viewContext)
newTag.name = trimmedText
newTag.slug = trimmedText.lowercased().replacingOccurrences(of: " ", with: "-")
newTag.createdAt = Date()
do {
try viewContext.save()
selectedTags.append(newTag)
} catch {
print("Error creating tag: \(error)")
}
}
tagSearchText = ""
}
private func saveLink() {
// Clear any previous error
saveErrorMessage = ""
showingSaveError = false
// Validate before saving
if !aliasValidationMessage.isEmpty {
saveErrorMessage = aliasValidationMessage
showingSaveError = true
return
}
if !urlValidationMessage.isEmpty && selectedLinkType != .custom {
saveErrorMessage = urlValidationMessage
showingSaveError = true
return
}
let success: Bool
if isEditing, let link = link {
success = linkViewModel.updateLink(
link,
alias: alias,
originalURL: selectedLinkType == .custom ? nil : originalURL,
text: selectedLinkType == .custom ? text : nil,
linkType: selectedLinkType,
description: linkDescription.isEmpty ? nil : linkDescription,
tags: selectedTags
)
} else {
success = linkViewModel.createLink(
alias: alias,
originalURL: selectedLinkType == .custom ? nil : originalURL,
text: selectedLinkType == .custom ? text : nil,
linkType: selectedLinkType,
description: linkDescription.isEmpty ? nil : linkDescription,
tags: selectedTags
)
}
if success {
presentationMode.wrappedValue.dismiss()
} else {
// Show error from view model
if let errorMessage = linkViewModel.errorMessage {
saveErrorMessage = errorMessage
showingSaveError = true
linkViewModel.errorMessage = nil // Clear the error
}
}
}
}
struct TagChip: View {
let tag: Tag
let onRemove: () -> Void
var body: some View {
HStack(spacing: 4) {
Text(tag.name)
.font(.caption)
Button(action: onRemove) {
Image(systemName: "xmark")
.font(.caption2)
}
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(Color.blue.opacity(0.2))
.foregroundColor(.blue)
.cornerRadius(6)
}
}
#Preview {
LinkFormView(link: nil)
.environmentObject(LinkViewModel())
.environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
}