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

486 lines
18 KiB
Swift

import SwiftUI
struct SearchView: View {
@EnvironmentObject var linkViewModel: LinkViewModel
@State private var searchText: String = ""
@State private var searchHistory: [String] = []
@State private var selectedFilters: Set<LinkType> = []
@State private var selectedTags: Set<Tag> = []
@State private var showingAdvancedFilters = false
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \Tag.name, ascending: true)],
animation: .default)
private var allTags: FetchedResults<Tag>
var filteredLinks: [Link] {
var links = linkViewModel.links
// Apply search text filter
if !searchText.isEmpty {
links = links.filter { link in
link.alias.localizedCaseInsensitiveContains(searchText) ||
(link.originalURL?.localizedCaseInsensitiveContains(searchText) ?? false) ||
(link.linkDescription?.localizedCaseInsensitiveContains(searchText) ?? false) ||
(link.text?.localizedCaseInsensitiveContains(searchText) ?? false) ||
link.sortedTags.contains { tag in
tag.name.localizedCaseInsensitiveContains(searchText)
}
}
}
// Apply type filters
if !selectedFilters.isEmpty {
links = links.filter { link in
selectedFilters.contains(link.linkTypeEnum)
}
}
// Apply tag filters
if !selectedTags.isEmpty {
links = links.filter { link in
let linkTags = Set(link.sortedTags)
return !selectedTags.intersection(linkTags).isEmpty
}
}
return links
}
var body: some View {
NavigationView {
VStack {
// Search bar
HStack {
TextField("search_links", text: $searchText)
.textFieldStyle(RoundedBorderTextFieldStyle())
.onSubmit {
performSearch()
}
Button("search") {
performSearch()
}
.disabled(searchText.isEmpty)
}
.padding(.horizontal)
// Quick actions
HStack {
Button(action: {
showingAdvancedFilters.toggle()
}) {
HStack {
Image(systemName: "slider.horizontal.3")
Text("filters")
if !selectedFilters.isEmpty || !selectedTags.isEmpty {
Text("(\(selectedFilters.count + selectedTags.count))")
.foregroundColor(.blue)
}
}
}
Spacer()
if !searchText.isEmpty && filteredLinks.isEmpty {
Button("open_as_url") {
openSearchAsURL()
}
.foregroundColor(.blue)
}
}
.padding(.horizontal)
.padding(.vertical, 8)
// Advanced filters (collapsible)
if showingAdvancedFilters {
VStack {
// Type filters
VStack(alignment: .leading) {
Text("link_types")
.font(.headline)
.padding(.horizontal)
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(LinkType.allCases, id: \.self) { linkType in
FilterChip(
title: linkType.displayName,
isSelected: selectedFilters.contains(linkType),
onTap: {
if selectedFilters.contains(linkType) {
selectedFilters.remove(linkType)
} else {
selectedFilters.insert(linkType)
}
}
)
}
}
.padding(.horizontal)
}
}
// Tag filters
if !allTags.isEmpty {
VStack(alignment: .leading) {
Text("tags")
.font(.headline)
.padding(.horizontal)
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(Array(allTags.prefix(10)), id: \.id) { tag in
FilterChip(
title: tag.name,
isSelected: selectedTags.contains(tag),
onTap: {
if selectedTags.contains(tag) {
selectedTags.remove(tag)
} else {
selectedTags.insert(tag)
}
}
)
}
}
.padding(.horizontal)
}
}
}
// Clear filters button
if !selectedFilters.isEmpty || !selectedTags.isEmpty {
Button("clear_filters") {
selectedFilters.removeAll()
selectedTags.removeAll()
}
.foregroundColor(.red)
.padding(.horizontal)
}
}
.padding(.vertical)
.background(Color(.systemGray6))
}
// Results
if searchText.isEmpty && selectedFilters.isEmpty && selectedTags.isEmpty {
// Show search suggestions and history
SearchSuggestionsView(
searchHistory: searchHistory,
allTags: Array(allTags),
onSearchTap: { suggestion in
searchText = suggestion
performSearch()
}
)
} else if filteredLinks.isEmpty {
// No results
VStack {
Image(systemName: "magnifyingglass")
.font(.system(size: 60))
.foregroundColor(.secondary)
Text("no_results_found")
.font(.title2)
.foregroundColor(.secondary)
if !searchText.isEmpty {
Button("open_as_url") {
openSearchAsURL()
}
.padding(.top)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
// Results list
List {
ForEach(filteredLinks) { link in
SearchResultRow(link: link)
}
}
}
}
.navigationTitle("search")
.navigationBarTitleDisplayMode(.large)
}
.onAppear {
loadSearchHistory()
}
}
private func performSearch() {
let trimmedText = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmedText.isEmpty else { return }
// Add to search history
if !searchHistory.contains(trimmedText) {
searchHistory.insert(trimmedText, at: 0)
searchHistory = Array(searchHistory.prefix(10)) // Keep only last 10
saveSearchHistory()
}
// Hide keyboard
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
private func openSearchAsURL() {
let urlString = searchText.hasPrefix("http") ? searchText : "https://\(searchText)"
if let url = URL(string: urlString) {
UIApplication.shared.open(url)
}
}
private func loadSearchHistory() {
if let data = UserDefaults.standard.data(forKey: "searchHistory"),
let history = try? JSONDecoder().decode([String].self, from: data) {
searchHistory = history
}
}
private func saveSearchHistory() {
if let data = try? JSONEncoder().encode(searchHistory) {
UserDefaults.standard.set(data, forKey: "searchHistory")
}
}
}
struct FilterChip: View {
let title: String
let isSelected: Bool
let onTap: () -> Void
var body: some View {
Button(action: onTap) {
Text(title)
.font(.caption)
.padding(.horizontal, 12)
.padding(.vertical, 6)
.background(isSelected ? Color.blue : Color(.systemGray5))
.foregroundColor(isSelected ? .white : .primary)
.cornerRadius(16)
}
}
}
struct SearchSuggestionsView: View {
let searchHistory: [String]
let allTags: [Tag]
let onSearchTap: (String) -> Void
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
// Search history
if !searchHistory.isEmpty {
VStack(alignment: .leading) {
Text("recent_searches")
.font(.headline)
.padding(.horizontal)
ForEach(searchHistory, id: \.self) { search in
Button(action: {
onSearchTap(search)
}) {
HStack {
Image(systemName: "clock")
.foregroundColor(.secondary)
Text(search)
.foregroundColor(.primary)
Spacer()
Image(systemName: "arrow.up.left")
.foregroundColor(.secondary)
}
.padding(.horizontal)
.padding(.vertical, 8)
}
}
}
}
// Popular tags
if !allTags.isEmpty {
VStack(alignment: .leading) {
Text("popular_tags")
.font(.headline)
.padding(.horizontal)
ScrollView(.horizontal, showsIndicators: false) {
HStack {
ForEach(allTags.prefix(10), id: \.id) { tag in
Button(action: {
onSearchTap(tag.name)
}) {
VStack {
Text(tag.name)
.font(.caption)
.fontWeight(.medium)
Text("\(tag.linkCount)")
.font(.caption2)
.foregroundColor(.secondary)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color(.systemGray6))
.cornerRadius(8)
}
.foregroundColor(.primary)
}
}
.padding(.horizontal)
}
}
}
// Quick tips
VStack(alignment: .leading) {
Text("search_tips")
.font(.headline)
.padding(.horizontal)
VStack(alignment: .leading, spacing: 8) {
SearchTip(
icon: "magnifyingglass",
title: "search_by_alias",
description: "search_by_alias_description"
)
SearchTip(
icon: "link",
title: "search_by_url",
description: "search_by_url_description"
)
SearchTip(
icon: "tag",
title: "search_by_tag",
description: "search_by_tag_description"
)
}
.padding(.horizontal)
}
}
.padding(.vertical)
}
}
}
struct SearchTip: View {
let icon: String
let title: String
let description: String
var body: some View {
HStack(alignment: .top) {
Image(systemName: icon)
.foregroundColor(.blue)
.frame(width: 20)
VStack(alignment: .leading, spacing: 2) {
Text(NSLocalizedString(title, comment: ""))
.font(.caption)
.fontWeight(.medium)
Text(NSLocalizedString(description, comment: ""))
.font(.caption2)
.foregroundColor(.secondary)
}
}
}
}
struct SearchResultRow: View {
let link: Link
@EnvironmentObject var linkViewModel: LinkViewModel
var body: some View {
HStack {
VStack(alignment: .leading, spacing: 4) {
// Alias with highlighting
Text(link.alias)
.font(.headline)
.foregroundColor(.primary)
// URL or content type
if let url = link.originalURL {
Text(url)
.font(.caption)
.foregroundColor(.secondary)
.lineLimit(1)
} else {
Text("custom_content")
.font(.caption)
.foregroundColor(.secondary)
.italic()
}
// Tags
if !link.sortedTags.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 4) {
ForEach(link.sortedTags.prefix(3), id: \.id) { tag in
Text(tag.name)
.font(.caption2)
.padding(.horizontal, 4)
.padding(.vertical, 2)
.background(Color.blue.opacity(0.2))
.foregroundColor(.blue)
.cornerRadius(4)
}
}
}
}
// Stats
HStack {
HStack(spacing: 2) {
Image(systemName: "hand.tap")
Text("\(link.clickCount)")
}
.font(.caption2)
.foregroundColor(.secondary)
Spacer()
Text(link.linkTypeEnum.displayName)
.font(.caption2)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(linkTypeColor(link.linkTypeEnum))
.foregroundColor(.white)
.cornerRadius(4)
}
}
Spacer()
Button(action: {
linkViewModel.openLink(link)
}) {
Image(systemName: "arrow.up.right.circle.fill")
.foregroundColor(.blue)
.font(.title2)
}
}
.padding(.vertical, 4)
}
private func linkTypeColor(_ linkType: LinkType) -> Color {
switch linkType {
case .link:
return .blue
case .custom:
return .green
case .template:
return .orange
}
}
}
#Preview {
SearchView()
.environmentObject(LinkViewModel())
.environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
}