Files
linkding-ios/ShareExtension/ShareView.swift
T
Krishna KumarandClaude Opus 5 fffb3999bf
CI / build-and-deploy (pull_request) Successful in 34s
CI / build-and-deploy (push) Successful in 34s
Carry the library design across the rest of the app
Every screen now speaks the paper vocabulary rather than only the
bookmarks screen: Search, Sources, Podcasts and the player, Settings,
Add/Edit, Smart Collections, Ask, Onboarding, the browser toolbar, the
Siri snippets, and the share extension's save card.

The change is mostly a design system rather than per-screen tweaks.
LibraryKit gains:

- Semantic colors — secondary/tertiary/faint text, `raised` surfaces,
  and accent/alarm/affirm, so screens stop reaching for .secondary,
  systemGray, .blue, .red and .green. The three status colors come out
  of the palette (the swatch blue, vermilion and forest) rather than
  from the system set, so the design keeps spending one set of inks.
- PaperType — the two voices made explicit. Prose (titles, summaries,
  anything a person wrote) is serif; anything the machine contributes
  (domains, dates, counts, tags, labels, buttons) is monospaced. Keeping
  that split strict is what makes the design read as archival rather
  than as decoration.
- paperSurface() / paperField() / paperCard() for grounds, inputs and
  raised blocks.
- PaperEmptyState, because ContentUnavailableView can't be restyled — it
  draws its own bold system type and grey, which was the loudest
  remaining system voice once the screens moved onto the sheet. All nine
  usages are converted.

The app also sets one accent for the system chrome it doesn't draw —
tab bar, search fields, switches, swipe actions — which otherwise stayed
system blue around paper screens.

BookmarkRow is deleted. Once Search moved to the library row and
TagBookmarksView was gone, nothing passed .classic, so the style fork in
BookmarkListRow went with it. There is one bookmark row now.

The share extension compiles LibraryKit directly, since its save card is
a user-facing surface and should not be the one place still using system
styling.

Verified on device in both color schemes; screens toured with a
temporary UI test harness (removed). Suite passes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JgLHztZGaEHvS3KNeGQmRM
2026-07-27 12:50:45 -05:00

347 lines
13 KiB
Swift

import SwiftUI
/// Interactive save card shown by the share extension. On open it checks whether
/// the URL is already bookmarked, then lets the user set/edit tags, notes, and a
/// read-later flag before saving (new) or updating (existing) — never a duplicate.
struct ShareView: View {
let url: String
let initialTitle: String?
let initialNotes: String
/// Called when the extension should dismiss. `saved` is true after a successful
/// write, false on cancel.
let onClose: (_ saved: Bool) -> Void
@State private var phase: Phase = .loading
@State private var existing: Bookmark?
@State private var title = ""
@State private var tagText = ""
@State private var notes = ""
@State private var readLater = false
@State private var createPodcast = false
@State private var suggestedTags: [String] = [] // linkding auto_tags
@State private var knownTags: Set<String> = [] // user's existing vocabulary (lowercased)
@State private var aiSuggestions: [String] = [] // net-new AI ideas (not in vocabulary)
@State private var suggesting = false
@State private var errorMessage = ""
@FocusState private var tagsFocused: Bool
private let api: LinkdingAPI?
init(url: String, initialTitle: String? = nil, initialNotes: String = "", onClose: @escaping (_ saved: Bool) -> Void) {
self.url = url
self.initialTitle = initialTitle
self.initialNotes = initialNotes
self.onClose = onClose
self.api = ServerConfig.loadShared().map(LinkdingAPI.init)
_title = State(initialValue: initialTitle ?? "")
_notes = State(initialValue: initialNotes)
}
private enum Phase { case loading, editing, saving, done, failed }
private static let relativeFormatter: RelativeDateTimeFormatter = {
let f = RelativeDateTimeFormatter()
f.unitsStyle = .abbreviated
f.dateTimeStyle = .named
return f
}()
var body: some View {
VStack {
Spacer()
card
.frame(maxWidth: 460)
.padding(.horizontal, 20)
Spacer()
}
.task { await check() }
}
@ViewBuilder
private var card: some View {
VStack(alignment: .leading, spacing: 16) {
switch phase {
case .loading:
HStack(spacing: 12) {
ProgressView()
Text("Checking…").font(PaperType.meta).foregroundStyle(Paper.secondary)
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 24)
case .editing, .saving:
header
editor
actions
case .done:
statusCard(icon: "checkmark.circle.fill", tint: .green,
text: existing != nil ? "Updated" : "Saved")
case .failed:
statusCard(icon: "xmark.circle.fill", tint: .red,
text: errorMessage.isEmpty ? "Something went wrong" : errorMessage)
}
}
.padding(20)
.background(Paper.sheet, in: RoundedRectangle(cornerRadius: 22, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 22, style: .continuous)
.stroke(Paper.rule.opacity(0.5), lineWidth: 0.6)
)
.shadow(color: .black.opacity(0.15), radius: 24, y: 8)
}
// MARK: - Sections
private var header: some View {
VStack(alignment: .leading, spacing: 4) {
if let existing {
Label {
Text(existing.dateAdded == .distantPast
? "Already saved"
: "Already saved · \(Self.relativeFormatter.localizedString(for: existing.dateAdded, relativeTo: Date()))")
} icon: {
Image(systemName: "checkmark.circle.fill").foregroundStyle(Paper.affirm)
}
.font(PaperType.label)
.foregroundStyle(Paper.ink)
} else {
Text("Save bookmark").font(PaperType.heading).foregroundStyle(Paper.ink)
}
Text(title.isEmpty ? url : title)
.font(PaperType.quote)
.foregroundStyle(Paper.ink)
.lineLimit(2)
Text(domain)
.font(PaperType.micro)
.foregroundStyle(Paper.tertiary)
}
}
private var editor: some View {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 6) {
TextField("Tags (space separated)", text: $tagText)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.focused($tagsFocused)
.textFieldStyle(.roundedBorder)
if suggesting && aiSuggestions.isEmpty {
HStack(spacing: 6) {
ProgressView().controlSize(.small)
Text("Suggesting tags…")
.font(PaperType.micro)
.foregroundStyle(Paper.tertiary)
}
}
if !suggestionChips.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 6) {
ForEach(suggestionChips, id: \.tag) { item in
Button { addTag(item.tag) } label: {
Text(item.isAI ? "✨ \(item.tag)" : "+ \(item.tag)")
.font(PaperType.micro)
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background((item.isAI ? Paper.accent : Paper.ink).opacity(0.1), in: Capsule())
.foregroundStyle(item.isAI ? Paper.accent : Paper.secondary)
}
.buttonStyle(.plain)
}
}
}
}
}
TextField("Notes", text: $notes, axis: .vertical)
.lineLimit(1...3)
.textFieldStyle(.roundedBorder)
Toggle("Read later", isOn: $readLater)
.font(PaperType.meta)
.foregroundStyle(Paper.secondary)
Toggle(isOn: $createPodcast) {
Label("Create podcast", systemImage: "headphones")
}
.font(PaperType.meta)
.foregroundStyle(Paper.secondary)
}
}
private var actions: some View {
HStack(spacing: 12) {
Button("Cancel") { onClose(false) }
.buttonStyle(.bordered)
.frame(maxWidth: .infinity)
Button {
Task { await commit() }
} label: {
Group {
if phase == .saving { ProgressView().tint(.white) }
else { Text(existing != nil ? "Update" : "Save") }
}
.frame(maxWidth: .infinity)
}
.buttonStyle(.borderedProminent)
.disabled(phase == .saving)
}
.padding(.top, 2)
}
private func statusCard(icon: String, tint: Color, text: String) -> some View {
HStack(spacing: 10) {
Image(systemName: icon).font(.system(size: 22)).foregroundStyle(tint)
Text(text).font(PaperType.heading).foregroundStyle(Paper.ink)
}
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 16)
}
// MARK: - Derived
private var domain: String { URL(string: url)?.host ?? url }
/// Tappable suggestion chips: net-new AI ideas (✨) followed by linkding's
/// auto_tags (+), excluding anything already in the field, de-duplicated.
private var suggestionChips: [(tag: String, isAI: Bool)] {
let current = Set(parsedTags.map { $0.lowercased() })
var seen = Set<String>()
var out: [(tag: String, isAI: Bool)] = []
for tag in aiSuggestions + suggestedTags {
let key = tag.lowercased()
let isAI = aiSuggestions.contains { $0.lowercased() == key }
if !current.contains(key) && seen.insert(key).inserted {
out.append((tag, isAI))
}
}
return out
}
private var parsedTags: [String] {
tagText.split(whereSeparator: { $0 == " " || $0 == "," })
.map { String($0) }
.filter { !$0.isEmpty }
}
private func addTag(_ tag: String) {
let trimmed = tagText.trimmingCharacters(in: .whitespaces)
tagText = trimmed.isEmpty ? tag : trimmed + " " + tag
tagText += " "
}
// MARK: - Networking
private func check() async {
guard let parsed = URL(string: url),
let scheme = parsed.scheme?.lowercased(),
scheme == "http" || scheme == "https",
parsed.host?.isEmpty == false else {
errorMessage = "No bookmarkable link found"
phase = .failed
return
}
guard let api else {
errorMessage = "Open Marks first"
phase = .failed
return
}
do {
let result = try await api.checkBookmark(url: url)
if let bookmark = result.bookmark {
existing = bookmark
title = bookmark.displayTitle
tagText = bookmark.tagNames.joined(separator: " ")
notes = bookmark.description
readLater = bookmark.unread
} else {
title = initialTitle ?? result.metadata?.title ?? ""
suggestedTags = result.autoTags ?? []
if notes.isEmpty {
notes = result.metadata?.description ?? initialNotes
}
}
// Pre-enable the toggle when the current tags match the auto-rule.
createPodcast = PodcastRequests.matchesAutoTag(parsedTags)
phase = .editing
await loadSuggestions()
} catch {
errorMessage = error.localizedDescription
phase = .failed
}
}
/// Fetches the user's existing tag vocabulary and AI tag ideas in parallel,
/// then applies the hybrid behavior: auto-fill suggestions that match the
/// existing vocabulary (new bookmarks only), surface the rest as chips.
/// Best-effort — never throws, never blocks saving.
private func loadSuggestions() async {
guard let api else { return }
suggesting = true
defer { suggesting = false }
async let vocabTask = api.fetchTags()
let ai = await TagSuggester.suggest(url: url, title: title)
let vocab = (try? await vocabTask) ?? []
knownTags = Set(vocab.map { $0.lowercased() })
if existing == nil {
// Auto-fill AI tags the user already uses; high confidence, low surprise.
let current = Set(parsedTags.map { $0.lowercased() })
for tag in ai where knownTags.contains(tag.lowercased()) && !current.contains(tag.lowercased()) {
addTag(tag)
}
}
// Everything not in the vocabulary becomes a tappable ✨ chip.
aiSuggestions = ai.filter { !knownTags.contains($0.lowercased()) }
}
private func commit() async {
guard let api else { return }
phase = .saving
do {
if let existing {
let update = BookmarkUpdate(
url: existing.url,
title: existing.title,
description: notes,
tagNames: parsedTags,
unread: readLater,
shared: existing.shared
)
_ = try await api.updateBookmark(id: existing.id, update: update)
} else {
let create = BookmarkCreate(
url: url,
title: "",
description: notes,
tagNames: parsedTags,
isArchived: false,
unread: readLater,
shared: false
)
_ = try await api.createBookmark(create)
}
// Hand off podcast generation to the main app (the extension is too
// short-lived to generate audio itself).
if createPodcast || PodcastRequests.matchesAutoTag(parsedTags) {
PodcastRequests.enqueue(url: url, title: title.isEmpty ? url : title)
}
phase = .done
try? await Task.sleep(nanoseconds: 700_000_000)
onClose(true)
} catch {
errorMessage = error.localizedDescription
phase = .failed
try? await Task.sleep(nanoseconds: 1_600_000_000)
onClose(false)
}
}
}