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:
129
ShareExtension/ShareViewController.swift
Normal file
129
ShareExtension/ShareViewController.swift
Normal file
@@ -0,0 +1,129 @@
|
||||
import UIKit
|
||||
import UniformTypeIdentifiers
|
||||
|
||||
@objc(ShareViewController)
|
||||
class ShareViewController: UIViewController {
|
||||
|
||||
private let appGroupId = "group.com.magicive.marks"
|
||||
private let configKey = "serverConfig"
|
||||
|
||||
override func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
let blur = UIVisualEffectView(effect: UIBlurEffect(style: .systemMaterial))
|
||||
blur.frame = view.bounds
|
||||
blur.autoresizingMask = [.flexibleWidth, .flexibleHeight]
|
||||
view.addSubview(blur)
|
||||
extractURLAndSave()
|
||||
}
|
||||
|
||||
private func extractURLAndSave() {
|
||||
guard let item = extensionContext?.inputItems.first as? NSExtensionItem,
|
||||
let attachments = item.attachments else {
|
||||
showResult(success: false, message: "Nothing to save")
|
||||
return
|
||||
}
|
||||
|
||||
func load(typeId: String) -> Bool {
|
||||
guard let attachment = attachments.first(where: { $0.hasItemConformingToTypeIdentifier(typeId) }) else {
|
||||
return false
|
||||
}
|
||||
attachment.loadItem(forTypeIdentifier: typeId, options: nil) { [weak self] item, _ in
|
||||
let urlString = typeId == UTType.url.identifier
|
||||
? ((item as? URL)?.absoluteString ?? (item as? String))
|
||||
: (item as? String)
|
||||
DispatchQueue.main.async {
|
||||
if let url = urlString { self?.save(url: url) }
|
||||
else { self?.showResult(success: false, message: "No URL found") }
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
if !load(typeId: UTType.url.identifier) && !load(typeId: UTType.plainText.identifier) {
|
||||
showResult(success: false, message: "No URL found")
|
||||
}
|
||||
}
|
||||
|
||||
private func save(url: String) {
|
||||
guard let groupDefaults = UserDefaults(suiteName: appGroupId),
|
||||
let data = groupDefaults.data(forKey: configKey),
|
||||
let config = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let host = config["host"] as? String,
|
||||
let token = config["token"] as? String,
|
||||
let useHttps = config["useHttps"] as? Bool else {
|
||||
showResult(success: false, message: "Open Marks first")
|
||||
return
|
||||
}
|
||||
|
||||
var base = (useHttps ? "https" : "http") + "://" + host
|
||||
if let port = config["port"] as? Int { base += ":\(port)" }
|
||||
if let path = config["path"] as? String, !path.isEmpty { base += path }
|
||||
|
||||
guard let apiUrl = URL(string: "\(base)/api/bookmarks/") else {
|
||||
showResult(success: false, message: "Bad server URL")
|
||||
return
|
||||
}
|
||||
|
||||
var req = URLRequest(url: apiUrl)
|
||||
req.httpMethod = "POST"
|
||||
req.setValue("Token \(token)", forHTTPHeaderField: "Authorization")
|
||||
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
req.httpBody = try? JSONSerialization.data(withJSONObject: [
|
||||
"url": url, "is_archived": false, "unread": false, "shared": false
|
||||
])
|
||||
|
||||
URLSession.shared.dataTask(with: req) { [weak self] _, response, error in
|
||||
DispatchQueue.main.async {
|
||||
let code = (response as? HTTPURLResponse)?.statusCode ?? 0
|
||||
let ok = error == nil && (code == 200 || code == 201)
|
||||
self?.showResult(success: ok, message: ok ? "Bookmarked" : "Failed to save")
|
||||
}
|
||||
}.resume()
|
||||
}
|
||||
|
||||
private func showResult(success: Bool, message: String) {
|
||||
let card = UIView()
|
||||
card.backgroundColor = success ? .systemGreen : .systemRed
|
||||
card.layer.cornerRadius = 16
|
||||
card.layer.cornerCurve = .continuous
|
||||
card.translatesAutoresizingMaskIntoConstraints = false
|
||||
card.alpha = 0
|
||||
|
||||
let stack = UIStackView()
|
||||
stack.axis = .horizontal
|
||||
stack.spacing = 10
|
||||
stack.alignment = .center
|
||||
stack.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
let icon = UIImageView(image: UIImage(systemName: success ? "checkmark.circle.fill" : "xmark.circle.fill"))
|
||||
icon.tintColor = .white
|
||||
icon.preferredSymbolConfiguration = .init(pointSize: 22, weight: .semibold)
|
||||
|
||||
let label = UILabel()
|
||||
label.text = message
|
||||
label.textColor = .white
|
||||
label.font = .systemFont(ofSize: 17, weight: .semibold)
|
||||
|
||||
stack.addArrangedSubview(icon)
|
||||
stack.addArrangedSubview(label)
|
||||
card.addSubview(stack)
|
||||
view.addSubview(card)
|
||||
|
||||
NSLayoutConstraint.activate([
|
||||
stack.centerXAnchor.constraint(equalTo: card.centerXAnchor),
|
||||
stack.centerYAnchor.constraint(equalTo: card.centerYAnchor),
|
||||
stack.leadingAnchor.constraint(greaterThanOrEqualTo: card.leadingAnchor, constant: 24),
|
||||
card.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
||||
card.centerYAnchor.constraint(equalTo: view.centerYAnchor),
|
||||
card.heightAnchor.constraint(equalToConstant: 56),
|
||||
card.widthAnchor.constraint(greaterThanOrEqualToConstant: 180),
|
||||
])
|
||||
|
||||
UIView.animate(withDuration: 0.2) { card.alpha = 1 }
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + (success ? 0.8 : 2.0)) { [weak self] in
|
||||
UIView.animate(withDuration: 0.15, animations: { card.alpha = 0 }) { _ in
|
||||
self?.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user