summaryrefslogtreecommitdiff
path: root/Hutch/Views/Search
diff options
context:
space:
mode:
Diffstat (limited to 'Hutch/Views/Search')
-rw-r--r--Hutch/Views/Search/RecentSearchSuggestions.swift26
-rw-r--r--Hutch/Views/Search/ScopedSearchHistoryStore.swift65
2 files changed, 91 insertions, 0 deletions
diff --git a/Hutch/Views/Search/RecentSearchSuggestions.swift b/Hutch/Views/Search/RecentSearchSuggestions.swift
new file mode 100644
index 0000000..d40640e
--- /dev/null
+++ b/Hutch/Views/Search/RecentSearchSuggestions.swift
@@ -0,0 +1,26 @@
+import SwiftUI
+
+struct RecentSearchSuggestions: View {
+ let title: String
+ let entries: [ScopedSearchHistoryEntry]
+ let onSelect: (String) -> Void
+ let onClear: () -> Void
+
+ var body: some View {
+ if !entries.isEmpty {
+ Section(title) {
+ ForEach(entries) { entry in
+ Button {
+ onSelect(entry.query)
+ } label: {
+ Label(entry.query, systemImage: "clock.arrow.circlepath")
+ }
+ }
+
+ Button("Clear Recent Searches", role: .destructive) {
+ onClear()
+ }
+ }
+ }
+ }
+}
diff --git a/Hutch/Views/Search/ScopedSearchHistoryStore.swift b/Hutch/Views/Search/ScopedSearchHistoryStore.swift
new file mode 100644
index 0000000..e009376
--- /dev/null
+++ b/Hutch/Views/Search/ScopedSearchHistoryStore.swift
@@ -0,0 +1,65 @@
+import Foundation
+
+struct ScopedSearchHistoryEntry: Codable, Hashable, Identifiable, Sendable {
+ let scopeID: String
+ let query: String
+ let createdAt: Date
+
+ var id: String {
+ "\(scopeID):\(query.lowercased())"
+ }
+}
+
+enum ScopedSearchHistoryStore {
+ private static let maximumEntriesPerScope = 8
+
+ static func load(scopeID: String, defaults: UserDefaults = .standard) -> [ScopedSearchHistoryEntry] {
+ loadAll(defaults: defaults)[scopeID] ?? []
+ }
+
+ static func record(
+ query: String,
+ scopeID: String,
+ defaults: UserDefaults = .standard,
+ now: Date = .now
+ ) {
+ let normalizedQuery = query.trimmingCharacters(in: .whitespacesAndNewlines)
+ guard !normalizedQuery.isEmpty else { return }
+
+ var allEntries = loadAll(defaults: defaults)
+ var scopeEntries = allEntries[scopeID] ?? []
+ scopeEntries.removeAll {
+ $0.query.compare(normalizedQuery, options: [.caseInsensitive, .diacriticInsensitive]) == .orderedSame
+ }
+ scopeEntries.insert(
+ ScopedSearchHistoryEntry(scopeID: scopeID, query: normalizedQuery, createdAt: now),
+ at: 0
+ )
+ allEntries[scopeID] = Array(scopeEntries.prefix(maximumEntriesPerScope))
+ save(allEntries, defaults: defaults)
+ }
+
+ static func clear(scopeID: String, defaults: UserDefaults = .standard) {
+ var allEntries = loadAll(defaults: defaults)
+ allEntries.removeValue(forKey: scopeID)
+ save(allEntries, defaults: defaults)
+ }
+
+ private static func loadAll(defaults: UserDefaults) -> [String: [ScopedSearchHistoryEntry]] {
+ guard let data = defaults.data(forKey: AppStorageKeys.scopedSearchHistory) else {
+ return [:]
+ }
+
+ do {
+ return try JSONDecoder().decode([String: [ScopedSearchHistoryEntry]].self, from: data)
+ } catch {
+ defaults.removeObject(forKey: AppStorageKeys.scopedSearchHistory)
+ return [:]
+ }
+ }
+
+ private static func save(_ entries: [String: [ScopedSearchHistoryEntry]], defaults: UserDefaults) {
+ guard let data = try? JSONEncoder().encode(entries) else { return }
+ defaults.set(data, forKey: AppStorageKeys.scopedSearchHistory)
+ }
+}