Replace Flutter app with native SwiftUI rewrite (iOS 26)

Complete ground-up rewrite in SwiftUI targeting iOS 26. Drops the Flutter/Dart
codebase entirely in favour of a lean native app with no third-party dependencies.

Features shipped:
- Bookmark list with pagination, pull-to-refresh, swipe delete/archive
- Add bookmark form + iOS share extension (zero-tap save from any app)
- Tags tab — all tags sorted by count, tap to browse filtered bookmarks
- Native search tab (Tab role: .search) with instant client-side filtering
- AI enrichment via OpenRouter (google/gemini-2.0-flash-lite-001): auto-summary
  and tag generation, semantic search, smart collections
- Settings: linkding server config, OpenRouter API key
- App Groups for credential sharing between main app and share extension
- Swift 6 strict concurrency throughout (@Observable, @MainActor)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krishna Kumar
2026-05-21 08:33:01 -05:00
parent cd85f9c67f
commit b7ef5c2215
306 changed files with 2337 additions and 17949 deletions

View File

@@ -0,0 +1,88 @@
import SwiftUI
struct AddBookmarkView: View {
@Bindable var viewModel: BookmarksViewModel
@Environment(\.dismiss) private var dismiss
@State private var url = ""
@State private var title = ""
@State private var tagsText = ""
@State private var isSaving = false
@State private var error: String?
var body: some View {
NavigationStack {
Form {
Section {
TextField("https://", text: $url)
.keyboardType(.URL)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
} header: {
Text("URL")
}
Section {
TextField("Optional", text: $title)
} header: {
Text("Title")
}
Section {
TextField("comma separated", text: $tagsText)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
} header: {
Text("Tags")
} footer: {
Text("Separate tags with commas")
}
if let error {
Section {
Text(error)
.foregroundStyle(.red)
.font(.footnote)
}
}
}
.navigationTitle("Add Bookmark")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button("Cancel") { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button("Save") { save() }
.disabled(url.trimmingCharacters(in: .whitespaces).isEmpty || isSaving)
.overlay {
if isSaving { ProgressView().scaleEffect(0.7) }
}
}
}
}
}
private func save() {
var urlString = url.trimmingCharacters(in: .whitespaces)
if !urlString.hasPrefix("http://") && !urlString.hasPrefix("https://") {
urlString = "https://" + urlString
}
guard URL(string: urlString) != nil else {
error = "Invalid URL"
return
}
let tags = tagsText.split(separator: ",").map { $0.trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty }
isSaving = true
error = nil
Task {
do {
try await viewModel.addBookmark(url: urlString, title: title.trimmingCharacters(in: .whitespaces), tags: tags)
dismiss()
} catch {
self.error = error.localizedDescription
isSaving = false
}
}
}
}

View File

@@ -0,0 +1,93 @@
import SwiftUI
struct BookmarkRow: View {
let bookmark: Bookmark
var body: some View {
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .top, spacing: 11) {
FaviconView(url: bookmark.faviconUrl)
.padding(.top, 1)
VStack(alignment: .leading, spacing: 4) {
Text(bookmark.displayTitle)
.font(.system(size: 22, weight: .medium))
.foregroundStyle(.primary)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
Text(bookmark.domain)
.font(.system(size: 16))
.foregroundStyle(.tertiary)
}
}
if let summary = bookmark.aiSummary {
Text(summary)
.font(.system(size: 15))
.foregroundStyle(.secondary)
.lineLimit(2)
.padding(.leading, 40)
}
if !effectiveTags.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(effectiveTags, id: \.self) { tag in
Text(tag)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.blue)
.padding(.horizontal, 9)
.padding(.vertical, 4)
.background(Color.blue.opacity(0.1))
.clipShape(Capsule())
}
}
}
.padding(.leading, 40)
}
}
.padding(.vertical, 14)
.contentShape(Rectangle())
}
private var effectiveTags: [String] {
let base = bookmark.tagNames
let ai = bookmark.aiTags ?? []
let extra = ai.filter { !base.contains($0) }.prefix(3)
return (base + extra).prefix(6).map { $0 }
}
}
struct FaviconView: View {
let url: String?
var body: some View {
Group {
if let urlString = url, let faviconUrl = URL(string: urlString) {
AsyncImage(url: faviconUrl) { phase in
switch phase {
case .success(let image):
image.resizable().scaledToFit()
default:
placeholder
}
}
} else {
placeholder
}
}
.frame(width: 26, height: 26)
.clipShape(RoundedRectangle(cornerRadius: 5))
}
private var placeholder: some View {
RoundedRectangle(cornerRadius: 4)
.fill(Color(.systemGray5))
.overlay {
Image(systemName: "bookmark")
.font(.system(size: 9, weight: .medium))
.foregroundStyle(Color(.systemGray2))
}
}
}

View File

@@ -0,0 +1,142 @@
import SwiftUI
struct BookmarksView: View {
@Bindable var viewModel: BookmarksViewModel
let onDisconnect: () -> Void
@Environment(\.openURL) private var openURL
@State private var showSettings = false
@State private var showCollections = false
@State private var showAddBookmark = false
var body: some View {
NavigationStack {
List {
ForEach(viewModel.bookmarks) { bookmark in
BookmarkRow(bookmark: bookmark)
.onTapGesture {
if let url = URL(string: bookmark.url) { openURL(url) }
}
.onAppear { maybeLoadMore(bookmark) }
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
.listRowSeparator(.visible)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
Task { await viewModel.delete(bookmark) }
} label: {
Label("Delete", systemImage: "trash")
}
}
.swipeActions(edge: .leading) {
Button {
Task { await viewModel.archive(bookmark) }
} label: {
Label("Archive", systemImage: "archivebox")
}
.tint(.orange)
}
}
if viewModel.isLoadingMore {
HStack { Spacer(); ProgressView(); Spacer() }
.listRowSeparator(.hidden)
}
}
.listStyle(.plain)
.navigationTitle("Bookmarks")
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
if viewModel.claude != nil {
Menu {
Button {
Task { await viewModel.generateSmartCollections() }
showCollections = true
} label: {
Label("Smart Collections", systemImage: "sparkles")
}
Button {
Task { await viewModel.enrichAll() }
} label: {
Label("Enrich All with AI", systemImage: "wand.and.stars")
}
} label: {
Image(systemName: "sparkles")
}
}
}
ToolbarItem(placement: .topBarTrailing) {
HStack(spacing: 16) {
Button { showAddBookmark = true } label: {
Image(systemName: "plus")
}
Button { showSettings = true } label: {
Image(systemName: "gearshape")
}
}
}
}
.overlay {
if viewModel.isLoading && viewModel.bookmarks.isEmpty {
ProgressView()
}
if !viewModel.isLoading && viewModel.bookmarks.isEmpty {
ContentUnavailableView("No Bookmarks", systemImage: "bookmark", description: Text("Bookmarks you save will appear here."))
}
}
.overlay(alignment: .bottom) {
if viewModel.enrichmentProgress > 0 {
enrichmentBanner
}
}
}
.sheet(isPresented: $showSettings) {
SettingsView(viewModel: viewModel, onDisconnect: onDisconnect)
}
.sheet(isPresented: $showCollections) {
CollectionsView(viewModel: viewModel)
}
.sheet(isPresented: $showAddBookmark) {
AddBookmarkView(viewModel: viewModel)
}
.refreshable {
await viewModel.load()
}
.task {
if viewModel.bookmarks.isEmpty {
await viewModel.load()
}
}
.alert("Error", isPresented: Binding(
get: { viewModel.error != nil },
set: { if !$0 { viewModel.error = nil } }
)) {
Button("OK") { viewModel.error = nil }
} message: {
Text(viewModel.error ?? "")
}
}
private var enrichmentBanner: some View {
HStack(spacing: 10) {
ProgressView(value: viewModel.enrichmentProgress)
.progressViewStyle(.linear)
.tint(.primary)
Text("Adding AI summaries…")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 20)
.padding(.vertical, 12)
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 16)
.padding(.bottom, 8)
}
private func maybeLoadMore(_ bookmark: Bookmark) {
guard let last = viewModel.bookmarks.last, last.id == bookmark.id,
viewModel.nextPageUrl != nil, !viewModel.isLoadingMore else { return }
Task { await viewModel.loadMore() }
}
}

View File

@@ -0,0 +1,220 @@
import Foundation
import Observation
@Observable
@MainActor
final class BookmarksViewModel {
var bookmarks: [Bookmark] = []
var isLoading = false
var isLoadingMore = false
var error: String?
var searchQuery = ""
var nextPageUrl: String?
var smartCollections: [SmartCollection] = []
var isGeneratingCollections = false
var enrichmentProgress: Double = 0
private let api: LinkdingAPI
private(set) var claude: ClaudeService?
private var enrichTask: Task<Void, Never>?
init(api: LinkdingAPI, claude: ClaudeService?) {
self.api = api
self.claude = claude
}
func updateClaude(_ service: ClaudeService?) {
claude = service
}
func load() async {
isLoading = true
error = nil
do {
let response = try await api.fetchBookmarks(search: searchQuery)
bookmarks = response.results
nextPageUrl = response.next
restoreAIData()
} catch {
self.error = error.localizedDescription
}
isLoading = false
startEnrichment()
}
func loadMore() async {
guard let next = nextPageUrl, !isLoadingMore else { return }
isLoadingMore = true
do {
let response = try await api.fetchBookmarksFromUrl(next)
bookmarks.append(contentsOf: response.results)
nextPageUrl = response.next
restoreAIData()
} catch {
self.error = error.localizedDescription
}
isLoadingMore = false
}
func search() async {
isLoading = true
do {
let response = try await api.fetchBookmarks(search: searchQuery)
bookmarks = response.results
nextPageUrl = response.next
restoreAIData()
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func semanticSearch() async {
guard let claude, !searchQuery.isEmpty else { return }
isLoading = true
do {
// Search over all loaded bookmarks semantically
let allResponse = try await api.fetchBookmarks(limit: 200)
var all = allResponse.results
// Restore AI data so summaries are available for ranking
restoreAIData(into: &all)
bookmarks = try await claude.semanticSearch(query: searchQuery, in: all)
nextPageUrl = nil
} catch {
self.error = error.localizedDescription
}
isLoading = false
}
func addBookmark(url: String, title: String, tags: [String]) async throws {
let create = BookmarkCreate(url: url, title: title, tagNames: tags, isArchived: false, unread: false, shared: false)
let bookmark = try await api.createBookmark(create)
bookmarks.insert(bookmark, at: 0)
print("[AI] addBookmark: saved id=\(bookmark.id) claude=\(claude != nil ? "present" : "nil")")
guard let claude else { return }
Task {
print("[AI] addBookmark: starting enrich for id=\(bookmark.id) url=\(bookmark.url)")
do {
let (summary, aiTags) = try await claude.enrich(bookmark: bookmark)
print("[AI] addBookmark: enrich success summary=\(summary.prefix(60)) tags=\(aiTags)")
guard let i = bookmarks.firstIndex(where: { $0.id == bookmark.id }) else {
print("[AI] addBookmark: bookmark id=\(bookmark.id) not found in list after enrich")
return
}
bookmarks[i].aiSummary = summary
bookmarks[i].aiTags = aiTags
saveAIData(for: bookmarks[i])
} catch {
print("[AI] addBookmark: enrich FAILED \(error)")
self.error = "AI enrichment failed: \(error.localizedDescription)"
}
}
}
func delete(_ bookmark: Bookmark) async {
do {
try await api.deleteBookmark(id: bookmark.id)
bookmarks.removeAll { $0.id == bookmark.id }
} catch {
self.error = error.localizedDescription
}
}
func archive(_ bookmark: Bookmark) async {
do {
try await api.archiveBookmark(id: bookmark.id)
bookmarks.removeAll { $0.id == bookmark.id }
} catch {
self.error = error.localizedDescription
}
}
func generateSmartCollections() async {
guard let claude, !bookmarks.isEmpty else { return }
isGeneratingCollections = true
do {
let allResponse = try await api.fetchBookmarks(limit: 200)
smartCollections = try await claude.generateCollections(from: allResponse.results)
} catch {
self.error = error.localizedDescription
}
isGeneratingCollections = false
}
func enrichAll() async {
guard let claude else { return }
let toEnrich = bookmarks.indices.filter { bookmarks[$0].aiSummary == nil }
guard !toEnrich.isEmpty else { return }
enrichmentProgress = 0
for (done, i) in toEnrich.enumerated() {
do {
let (summary, tags) = try await claude.enrich(bookmark: bookmarks[i])
bookmarks[i].aiSummary = summary
bookmarks[i].aiTags = tags
saveAIData(for: bookmarks[i])
enrichmentProgress = Double(done + 1) / Double(toEnrich.count)
} catch {
break
}
}
enrichmentProgress = 0
}
// Silently enrich new bookmarks that lack summaries (background, non-blocking)
private func startEnrichment() {
guard claude != nil else {
print("[AI] startEnrichment: skipped (no claude)")
return
}
enrichTask?.cancel()
enrichTask = Task { [weak self] in
guard let self else { return }
let indices = await MainActor.run { bookmarks.indices.filter { bookmarks[$0].aiSummary == nil }.prefix(5) }
print("[AI] startEnrichment: \(indices.count) bookmarks to enrich")
for i in indices {
guard !Task.isCancelled else { return }
guard let claude = await MainActor.run(body: { self.claude }) else { return }
do {
let bm = await MainActor.run { bookmarks[i] }
print("[AI] startEnrichment: enriching id=\(bm.id) \(bm.url)")
let (summary, tags) = try await claude.enrich(bookmark: bm)
print("[AI] startEnrichment: done id=\(bm.id) summary=\(summary.prefix(60))")
await MainActor.run {
guard i < bookmarks.count else { return }
bookmarks[i].aiSummary = summary
bookmarks[i].aiTags = tags
saveAIData(for: bookmarks[i])
}
} catch {
print("[AI] startEnrichment: enrich FAILED id=\(i) \(error)")
break
}
}
}
}
// MARK: AI persistence
private func saveAIData(for bookmark: Bookmark) {
var store = UserDefaults.standard.dictionary(forKey: "aiData") as? [String: [String: Any]] ?? [:]
store["\(bookmark.id)"] = [
"summary": bookmark.aiSummary ?? "",
"tags": bookmark.aiTags ?? []
]
UserDefaults.standard.set(store, forKey: "aiData")
}
private func restoreAIData() {
restoreAIData(into: &bookmarks)
}
private func restoreAIData(into list: inout [Bookmark]) {
let store = UserDefaults.standard.dictionary(forKey: "aiData") as? [String: [String: Any]] ?? [:]
for i in list.indices {
if let d = store["\(list[i].id)"] {
list[i].aiSummary = (d["summary"] as? String).flatMap { $0.isEmpty ? nil : $0 }
list[i].aiTags = d["tags"] as? [String]
}
}
}
}

View File

@@ -0,0 +1,66 @@
import SwiftUI
struct CollectionsView: View {
@Bindable var viewModel: BookmarksViewModel
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
Group {
if viewModel.isGeneratingCollections {
VStack(spacing: 16) {
ProgressView()
Text("Building smart collections…")
.font(.system(size: 15))
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if viewModel.smartCollections.isEmpty {
ContentUnavailableView(
"No Collections Yet",
systemImage: "sparkles",
description: Text("Tap \"Smart Collections\" to group your bookmarks by topic.")
)
} else {
List(viewModel.smartCollections) { collection in
Section {
let items = viewModel.bookmarks.filter { collection.bookmarkIds.contains($0.id) }
ForEach(items) { bookmark in
BookmarkRow(bookmark: bookmark)
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
}
} header: {
VStack(alignment: .leading, spacing: 2) {
Text(collection.name)
.font(.system(size: 14, weight: .semibold))
.foregroundStyle(.primary)
if !collection.description.isEmpty {
Text(collection.description)
.font(.system(size: 12))
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
}
.listStyle(.insetGrouped)
}
}
.navigationTitle("Smart Collections")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarLeading) {
Button {
Task { await viewModel.generateSmartCollections() }
} label: {
Image(systemName: "arrow.clockwise")
}
.disabled(viewModel.isGeneratingCollections)
}
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
}
}
}
}
}

View File

@@ -0,0 +1,144 @@
import SwiftUI
struct OnboardingView: View {
let onConnect: (ServerConfig) -> Void
@State private var urlText = ""
@State private var token = ""
@State private var isConnecting = false
@State private var errorMessage: String?
@FocusState private var focused: Field?
enum Field { case url, token }
var body: some View {
VStack(spacing: 0) {
Spacer()
VStack(alignment: .leading, spacing: 8) {
Text("Marks")
.font(.system(size: 42, weight: .semibold, design: .rounded))
Text("Your bookmarks, beautifully.")
.font(.system(size: 17))
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, 28)
.padding(.bottom, 48)
VStack(spacing: 14) {
VStack(alignment: .leading, spacing: 6) {
Text("Server URL")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 2)
TextField("https://links.example.com", text: $urlText)
.textContentType(.URL)
.keyboardType(.URL)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.focused($focused, equals: .url)
.submitLabel(.next)
.onSubmit { focused = .token }
.padding(14)
.background(Color(.systemGray6))
.clipShape(RoundedRectangle(cornerRadius: 12))
}
VStack(alignment: .leading, spacing: 6) {
Text("API Token")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.secondary)
.padding(.horizontal, 2)
SecureField("Paste your token", text: $token)
.textContentType(.password)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.focused($focused, equals: .token)
.submitLabel(.done)
.onSubmit { Task { await connect() } }
.padding(14)
.background(Color(.systemGray6))
.clipShape(RoundedRectangle(cornerRadius: 12))
Text("Find your token at Settings → API Token in Linkding.")
.font(.system(size: 12))
.foregroundStyle(.tertiary)
.padding(.horizontal, 2)
}
}
.padding(.horizontal, 24)
if let err = errorMessage {
Text(err)
.font(.system(size: 14))
.foregroundStyle(.red)
.padding(.top, 12)
.padding(.horizontal, 28)
}
Button {
Task { await connect() }
} label: {
Group {
if isConnecting {
ProgressView().tint(.white)
} else {
Text("Connect")
.font(.system(size: 17, weight: .semibold))
}
}
.frame(maxWidth: .infinity)
.frame(height: 52)
.background(canConnect ? Color.primary : Color(.systemGray4))
.foregroundStyle(canConnect ? Color(UIColor.systemBackground) : Color(.systemGray2))
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.disabled(!canConnect || isConnecting)
.padding(.horizontal, 24)
.padding(.top, 24)
Spacer()
}
}
private var canConnect: Bool { !urlText.isEmpty && !token.isEmpty }
private func connect() async {
focused = nil
isConnecting = true
errorMessage = nil
do {
let config = try parseConfig()
let api = LinkdingAPI(config: config)
try await api.verifyConnection()
onConnect(config)
} catch APIError.badStatus(401) {
errorMessage = "Invalid token. Check your API token in Linkding settings."
} catch APIError.badStatus(let code) {
errorMessage = "Server returned \(code). Check the URL."
} catch {
errorMessage = "Could not connect. Check the URL and try again."
}
isConnecting = false
}
private func parseConfig() throws -> ServerConfig {
var raw = urlText.trimmingCharacters(in: .whitespacesAndNewlines)
if !raw.hasPrefix("http://") && !raw.hasPrefix("https://") {
raw = "https://" + raw
}
guard let comps = URLComponents(string: raw),
let host = comps.host, !host.isEmpty else {
throw URLError(.badURL)
}
return ServerConfig(
host: host,
port: comps.port,
path: comps.path.hasSuffix("/") ? String(comps.path.dropLast()) : comps.path,
token: token.trimmingCharacters(in: .whitespacesAndNewlines),
useHttps: comps.scheme == "https"
)
}
}

View File

@@ -0,0 +1,87 @@
import SwiftUI
struct SearchView: View {
@Bindable var viewModel: BookmarksViewModel
@Environment(\.openURL) private var openURL
@State private var searchText = ""
@State private var useSemanticSearch = false
@State private var semanticResults: [Bookmark]? = nil
@State private var searchTask: Task<Void, Never>?
var body: some View {
NavigationStack {
List {
ForEach(results) { bookmark in
BookmarkRow(bookmark: bookmark)
.onTapGesture {
if let url = URL(string: bookmark.url) { openURL(url) }
}
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
.listRowSeparator(.visible)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
Task { await viewModel.delete(bookmark) }
} label: {
Label("Delete", systemImage: "trash")
}
}
}
}
.listStyle(.plain)
.navigationTitle("Search")
.toolbar {
if viewModel.claude != nil {
ToolbarItem(placement: .topBarTrailing) {
Toggle(isOn: $useSemanticSearch) {
Label("Semantic", systemImage: "sparkles")
}
.toggleStyle(.button)
.onChange(of: useSemanticSearch) { _, _ in scheduleSearch() }
}
}
}
.overlay {
if searchText.isEmpty {
ContentUnavailableView("Search Bookmarks", systemImage: "magnifyingglass", description: Text("Search by title, URL, or tag."))
} else if results.isEmpty && !viewModel.isLoading {
ContentUnavailableView.search(text: searchText)
}
if viewModel.isLoading {
ProgressView()
}
}
}
.searchable(text: $searchText, prompt: "Titles, URLs, tags…")
.onChange(of: searchText) { _, _ in scheduleSearch() }
}
private var results: [Bookmark] {
if let semantic = semanticResults { return semantic }
guard !searchText.isEmpty else { return [] }
let q = searchText.lowercased()
return viewModel.bookmarks.filter { b in
b.displayTitle.lowercased().contains(q) ||
b.url.lowercased().contains(q) ||
b.tagNames.contains { $0.lowercased().contains(q) } ||
(b.aiTags ?? []).contains { $0.lowercased().contains(q) } ||
(b.aiSummary ?? "").lowercased().contains(q)
}
}
private func scheduleSearch() {
semanticResults = nil
searchTask?.cancel()
guard !searchText.isEmpty, useSemanticSearch, let claude = viewModel.claude else { return }
searchTask = Task {
try? await Task.sleep(for: .milliseconds(500))
guard !Task.isCancelled else { return }
let q = searchText
let all = viewModel.bookmarks
do {
let ranked = try await claude.semanticSearch(query: q, in: all)
await MainActor.run { semanticResults = ranked }
} catch {}
}
}
}

View File

@@ -0,0 +1,58 @@
import SwiftUI
struct SettingsView: View {
@Bindable var viewModel: BookmarksViewModel
let onDisconnect: () -> Void
@State private var claudeKey = UserDefaults.standard.string(forKey: ClaudeService.defaultsKey) ?? ""
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
List {
Section("AI Features") {
VStack(alignment: .leading, spacing: 6) {
Text("OpenRouter API Key")
.font(.system(size: 13, weight: .medium))
.foregroundStyle(.secondary)
SecureField("sk-or-…", text: $claudeKey)
.textContentType(.password)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.onChange(of: claudeKey) { _, new in
ClaudeService.save(apiKey: new)
viewModel.updateClaude(new.isEmpty ? nil : ClaudeService(apiKey: new))
}
}
.padding(.vertical, 4)
if viewModel.claude != nil {
Label("AI enabled — semantic search, auto-tagging, and smart collections active.", systemImage: "sparkles")
.font(.system(size: 13))
.foregroundStyle(.secondary)
} else {
Text("Add an OpenRouter API key to enable AI features. Uses Gemini 2.0 Flash Lite — very cheap.")
.font(.system(size: 13))
.foregroundStyle(.secondary)
}
}
Section("Server") {
Button(role: .destructive) {
dismiss()
onDisconnect()
} label: {
Label("Disconnect", systemImage: "person.crop.circle.badge.minus")
}
}
}
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Done") { dismiss() }
}
}
}
}
}

View File

@@ -0,0 +1,94 @@
import SwiftUI
struct TagsView: View {
@Bindable var viewModel: BookmarksViewModel
var body: some View {
NavigationStack {
List {
ForEach(allTags, id: \.tag) { entry in
NavigationLink {
TagBookmarksView(tag: entry.tag, viewModel: viewModel)
} label: {
HStack {
Text(entry.tag)
.font(.system(size: 17))
Spacer()
Text("\(entry.count)")
.font(.system(size: 15))
.foregroundStyle(.secondary)
}
}
}
}
.navigationTitle("Tags")
.overlay {
if viewModel.bookmarks.isEmpty {
ContentUnavailableView("No Tags", systemImage: "tag", description: Text("Tags from your bookmarks will appear here."))
}
}
}
}
private var allTags: [(tag: String, count: Int)] {
var counts: [String: Int] = [:]
for bookmark in viewModel.bookmarks {
for tag in bookmark.tagNames {
counts[tag, default: 0] += 1
}
for tag in bookmark.aiTags ?? [] where !bookmark.tagNames.contains(tag) {
counts[tag, default: 0] += 1
}
}
return counts.map { (tag: $0.key, count: $0.value) }
.sorted { $0.count != $1.count ? $0.count > $1.count : $0.tag < $1.tag }
}
}
struct TagBookmarksView: View {
let tag: String
@Bindable var viewModel: BookmarksViewModel
@Environment(\.openURL) private var openURL
var body: some View {
List {
ForEach(filteredBookmarks) { bookmark in
BookmarkRow(bookmark: bookmark)
.onTapGesture {
if let url = URL(string: bookmark.url) { openURL(url) }
}
.listRowInsets(EdgeInsets(top: 0, leading: 20, bottom: 0, trailing: 20))
.listRowSeparator(.visible)
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
Button(role: .destructive) {
Task { await viewModel.delete(bookmark) }
} label: {
Label("Delete", systemImage: "trash")
}
}
.swipeActions(edge: .leading) {
Button {
Task { await viewModel.archive(bookmark) }
} label: {
Label("Archive", systemImage: "archivebox")
}
.tint(.orange)
}
}
}
.listStyle(.plain)
.navigationTitle(tag)
.navigationBarTitleDisplayMode(.large)
.overlay {
if filteredBookmarks.isEmpty {
ContentUnavailableView("No Bookmarks", systemImage: "tag")
}
}
}
private var filteredBookmarks: [Bookmark] {
viewModel.bookmarks.filter {
$0.tagNames.contains(tag) || ($0.aiTags ?? []).contains(tag)
}
}
}