feat: added history
13977ba9
4 file(s) · +226 −4
| 400 | 400 | DEVELOPMENT_TEAM = W8QNM2N67P; |
|
| 401 | 401 | ENABLE_PREVIEWS = YES; |
|
| 402 | 402 | GENERATE_INFOPLIST_FILE = YES; |
|
| 403 | - | INFOPLIST_KEY_CFBundleDisplayName = Titan; |
|
| 403 | + | INFOPLIST_KEY_CFBundleDisplayName = "Titan II"; |
|
| 404 | 404 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; |
|
| 405 | 405 | INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Titan needs access to save images from Gemini pages"; |
|
| 406 | 406 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; |
|
| 413 | 413 | "@executable_path/Frameworks", |
|
| 414 | 414 | ); |
|
| 415 | 415 | MARKETING_VERSION = 0.1; |
|
| 416 | - | PRODUCT_BUNDLE_IDENTIFIER = com.stevedylandev.Titan; |
|
| 416 | + | PRODUCT_BUNDLE_IDENTIFIER = com.stevedylandev.TitanII; |
|
| 417 | 417 | PRODUCT_NAME = "$(TARGET_NAME)"; |
|
| 418 | 418 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
|
| 419 | 419 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; |
|
| 439 | 439 | DEVELOPMENT_TEAM = W8QNM2N67P; |
|
| 440 | 440 | ENABLE_PREVIEWS = YES; |
|
| 441 | 441 | GENERATE_INFOPLIST_FILE = YES; |
|
| 442 | - | INFOPLIST_KEY_CFBundleDisplayName = Titan; |
|
| 442 | + | INFOPLIST_KEY_CFBundleDisplayName = "Titan II"; |
|
| 443 | 443 | INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; |
|
| 444 | 444 | INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Titan needs access to save images from Gemini pages"; |
|
| 445 | 445 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; |
|
| 452 | 452 | "@executable_path/Frameworks", |
|
| 453 | 453 | ); |
|
| 454 | 454 | MARKETING_VERSION = 0.1; |
|
| 455 | - | PRODUCT_BUNDLE_IDENTIFIER = com.stevedylandev.Titan; |
|
| 455 | + | PRODUCT_BUNDLE_IDENTIFIER = com.stevedylandev.TitanII; |
|
| 456 | 456 | PRODUCT_NAME = "$(TARGET_NAME)"; |
|
| 457 | 457 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
|
| 458 | 458 | SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; |
|
| 1 | + | // |
|
| 2 | + | // HistoryManager.swift |
|
| 3 | + | // Titan |
|
| 4 | + | // |
|
| 5 | + | ||
| 6 | + | import Foundation |
|
| 7 | + | import SwiftUI |
|
| 8 | + | ||
| 9 | + | struct HistoryItem: Identifiable, Codable, Equatable { |
|
| 10 | + | let id: UUID |
|
| 11 | + | let url: String |
|
| 12 | + | let title: String |
|
| 13 | + | let visitedAt: Date |
|
| 14 | + | ||
| 15 | + | init(id: UUID = UUID(), url: String, title: String, visitedAt: Date = Date()) { |
|
| 16 | + | self.id = id |
|
| 17 | + | self.url = url |
|
| 18 | + | self.title = title |
|
| 19 | + | self.visitedAt = visitedAt |
|
| 20 | + | } |
|
| 21 | + | } |
|
| 22 | + | ||
| 23 | + | @Observable |
|
| 24 | + | class HistoryManager { |
|
| 25 | + | private let storageKey = "titan_history" |
|
| 26 | + | ||
| 27 | + | var items: [HistoryItem] = [] |
|
| 28 | + | ||
| 29 | + | init() { |
|
| 30 | + | loadHistory() |
|
| 31 | + | } |
|
| 32 | + | ||
| 33 | + | func addToHistory(url: String, title: String) { |
|
| 34 | + | let item = HistoryItem(url: url, title: title) |
|
| 35 | + | items.insert(item, at: 0) |
|
| 36 | + | saveHistory() |
|
| 37 | + | } |
|
| 38 | + | ||
| 39 | + | func removeItem(_ item: HistoryItem) { |
|
| 40 | + | items.removeAll { $0.id == item.id } |
|
| 41 | + | saveHistory() |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | func removeItems(at offsets: IndexSet) { |
|
| 45 | + | items.remove(atOffsets: offsets) |
|
| 46 | + | saveHistory() |
|
| 47 | + | } |
|
| 48 | + | ||
| 49 | + | func clearAll() { |
|
| 50 | + | items.removeAll() |
|
| 51 | + | saveHistory() |
|
| 52 | + | } |
|
| 53 | + | ||
| 54 | + | private func saveHistory() { |
|
| 55 | + | if let data = try? JSONEncoder().encode(items) { |
|
| 56 | + | UserDefaults.standard.set(data, forKey: storageKey) |
|
| 57 | + | } |
|
| 58 | + | } |
|
| 59 | + | ||
| 60 | + | private func loadHistory() { |
|
| 61 | + | guard let data = UserDefaults.standard.data(forKey: storageKey), |
|
| 62 | + | let decoded = try? JSONDecoder().decode([HistoryItem].self, from: data) else { |
|
| 63 | + | return |
|
| 64 | + | } |
|
| 65 | + | items = decoded |
|
| 66 | + | } |
|
| 67 | + | } |
| 55 | 55 | @State private var bookmarkManager = BookmarkManager() |
|
| 56 | 56 | @State private var showBookmarks = false |
|
| 57 | 57 | ||
| 58 | + | // History |
|
| 59 | + | @State private var historyManager = HistoryManager() |
|
| 60 | + | @State private var showHistory = false |
|
| 61 | + | ||
| 58 | 62 | // Settings |
|
| 59 | 63 | @State private var showSettings = false |
|
| 60 | 64 | ||
| 148 | 152 | } |
|
| 149 | 153 | } |
|
| 150 | 154 | .disabled(urlText.isEmpty || bookmarkManager.isBookmarked(url: urlText)) |
|
| 155 | + | ||
| 156 | + | Button { |
|
| 157 | + | showHistory = true |
|
| 158 | + | } label: { |
|
| 159 | + | Label("History", systemImage: "clock") |
|
| 160 | + | } |
|
| 151 | 161 | ||
| 152 | 162 | Divider() |
|
| 153 | 163 | ||
| 206 | 216 | .sheet(isPresented: $showSettings) { |
|
| 207 | 217 | SettingsView() |
|
| 208 | 218 | } |
|
| 219 | + | .sheet(isPresented: $showHistory) { |
|
| 220 | + | HistoryListView(historyManager: historyManager) { item in |
|
| 221 | + | showHistory = false |
|
| 222 | + | navigateTo(item.url) |
|
| 223 | + | } |
|
| 224 | + | } |
|
| 209 | 225 | } |
|
| 210 | 226 | ||
| 211 | 227 | private func submitInput() { |
|
| 319 | 335 | if addToHistory { |
|
| 320 | 336 | history.append(finalURL) |
|
| 321 | 337 | historyIndex = history.count - 1 |
|
| 338 | + | ||
| 339 | + | // Add to persistent history |
|
| 340 | + | let title = extractPageTitle() ?? finalURL |
|
| 341 | + | historyManager.addToHistory(url: finalURL, title: title) |
|
| 322 | 342 | } |
|
| 323 | 343 | } |
|
| 324 | 344 | case .input: |
|
| 1 | + | // |
|
| 2 | + | // HistoryListView.swift |
|
| 3 | + | // Titan |
|
| 4 | + | // |
|
| 5 | + | ||
| 6 | + | import SwiftUI |
|
| 7 | + | ||
| 8 | + | struct HistoryListView: View { |
|
| 9 | + | @Bindable var historyManager: HistoryManager |
|
| 10 | + | let onSelect: (HistoryItem) -> Void |
|
| 11 | + | ||
| 12 | + | @Environment(\.dismiss) private var dismiss |
|
| 13 | + | @State private var showClearConfirmation = false |
|
| 14 | + | ||
| 15 | + | private var groupedHistory: [(String, [HistoryItem])] { |
|
| 16 | + | let calendar = Calendar.current |
|
| 17 | + | let now = Date() |
|
| 18 | + | ||
| 19 | + | var groups: [String: [HistoryItem]] = [:] |
|
| 20 | + | ||
| 21 | + | for item in historyManager.items { |
|
| 22 | + | let key: String |
|
| 23 | + | if calendar.isDateInToday(item.visitedAt) { |
|
| 24 | + | key = "Today" |
|
| 25 | + | } else if calendar.isDateInYesterday(item.visitedAt) { |
|
| 26 | + | key = "Yesterday" |
|
| 27 | + | } else if let weekAgo = calendar.date(byAdding: .day, value: -7, to: now), |
|
| 28 | + | item.visitedAt > weekAgo { |
|
| 29 | + | key = "This Week" |
|
| 30 | + | } else { |
|
| 31 | + | key = "Older" |
|
| 32 | + | } |
|
| 33 | + | ||
| 34 | + | groups[key, default: []].append(item) |
|
| 35 | + | } |
|
| 36 | + | ||
| 37 | + | let order = ["Today", "Yesterday", "This Week", "Older"] |
|
| 38 | + | return order.compactMap { key in |
|
| 39 | + | guard let items = groups[key], !items.isEmpty else { return nil } |
|
| 40 | + | return (key, items) |
|
| 41 | + | } |
|
| 42 | + | } |
|
| 43 | + | ||
| 44 | + | var body: some View { |
|
| 45 | + | NavigationStack { |
|
| 46 | + | Group { |
|
| 47 | + | if historyManager.items.isEmpty { |
|
| 48 | + | ContentUnavailableView( |
|
| 49 | + | "No History", |
|
| 50 | + | systemImage: "clock", |
|
| 51 | + | description: Text("Pages you visit will appear here") |
|
| 52 | + | ) |
|
| 53 | + | } else { |
|
| 54 | + | List { |
|
| 55 | + | ForEach(groupedHistory, id: \.0) { section, items in |
|
| 56 | + | Section(section) { |
|
| 57 | + | ForEach(items) { item in |
|
| 58 | + | Button { |
|
| 59 | + | onSelect(item) |
|
| 60 | + | } label: { |
|
| 61 | + | VStack(alignment: .leading, spacing: 4) { |
|
| 62 | + | Text(item.title) |
|
| 63 | + | .font(.system(.body, design: .monospaced)) |
|
| 64 | + | .foregroundStyle(.primary) |
|
| 65 | + | .lineLimit(2) |
|
| 66 | + | ||
| 67 | + | HStack { |
|
| 68 | + | Text(item.url) |
|
| 69 | + | .font(.system(.caption, design: .monospaced)) |
|
| 70 | + | .foregroundStyle(.secondary) |
|
| 71 | + | .lineLimit(1) |
|
| 72 | + | ||
| 73 | + | Spacer() |
|
| 74 | + | ||
| 75 | + | Text(item.visitedAt, style: .time) |
|
| 76 | + | .font(.system(.caption2, design: .monospaced)) |
|
| 77 | + | .foregroundStyle(.tertiary) |
|
| 78 | + | } |
|
| 79 | + | } |
|
| 80 | + | .padding(.vertical, 4) |
|
| 81 | + | } |
|
| 82 | + | } |
|
| 83 | + | .onDelete { offsets in |
|
| 84 | + | deleteItems(in: items, at: offsets) |
|
| 85 | + | } |
|
| 86 | + | } |
|
| 87 | + | } |
|
| 88 | + | } |
|
| 89 | + | } |
|
| 90 | + | } |
|
| 91 | + | .navigationTitle("History") |
|
| 92 | + | .navigationBarTitleDisplayMode(.inline) |
|
| 93 | + | .toolbar { |
|
| 94 | + | ToolbarItem(placement: .cancellationAction) { |
|
| 95 | + | Button("Done") { |
|
| 96 | + | dismiss() |
|
| 97 | + | } |
|
| 98 | + | } |
|
| 99 | + | ||
| 100 | + | if !historyManager.items.isEmpty { |
|
| 101 | + | ToolbarItem(placement: .primaryAction) { |
|
| 102 | + | Menu { |
|
| 103 | + | Button(role: .destructive) { |
|
| 104 | + | showClearConfirmation = true |
|
| 105 | + | } label: { |
|
| 106 | + | Label("Clear All History", systemImage: "trash") |
|
| 107 | + | } |
|
| 108 | + | } label: { |
|
| 109 | + | Image(systemName: "ellipsis.circle") |
|
| 110 | + | } |
|
| 111 | + | } |
|
| 112 | + | } |
|
| 113 | + | } |
|
| 114 | + | .confirmationDialog( |
|
| 115 | + | "Clear All History", |
|
| 116 | + | isPresented: $showClearConfirmation, |
|
| 117 | + | titleVisibility: .visible |
|
| 118 | + | ) { |
|
| 119 | + | Button("Clear All", role: .destructive) { |
|
| 120 | + | historyManager.clearAll() |
|
| 121 | + | } |
|
| 122 | + | Button("Cancel", role: .cancel) {} |
|
| 123 | + | } message: { |
|
| 124 | + | Text("This will permanently delete all browsing history.") |
|
| 125 | + | } |
|
| 126 | + | } |
|
| 127 | + | } |
|
| 128 | + | ||
| 129 | + | private func deleteItems(in sectionItems: [HistoryItem], at offsets: IndexSet) { |
|
| 130 | + | for offset in offsets { |
|
| 131 | + | let item = sectionItems[offset] |
|
| 132 | + | historyManager.removeItem(item) |
|
| 133 | + | } |
|
| 134 | + | } |
|
| 135 | + | } |