Modernize Share UI: SwiftUI card, modern text field, label grid, visual improvements\n\n- Refactor ShareViewController to use SwiftUI (UIHostingController)\n- Add ShareBookmarkView, ShareBookmarkViewModel, LabelGridView\n- Title text field with card style, shadow, clear border, fixed height (38pt)\n- Highlight URL with icon and accentColor\n- Increase label grid to 15 labels, accentColor for selection\n- Compact, centered card instead of fullscreen layout\n- Update various strings and project files
This commit is contained in:
parent
89c1c3c892
commit
bdd7d234a9
@ -22,6 +22,9 @@
|
|||||||
},
|
},
|
||||||
"%lld min" : {
|
"%lld min" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"%lld minutes" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"%lld." : {
|
"%lld." : {
|
||||||
|
|
||||||
@ -67,9 +70,18 @@
|
|||||||
},
|
},
|
||||||
"Are you sure you want to log out? This will delete all your login credentials and return you to setup." : {
|
"Are you sure you want to log out? This will delete all your login credentials and return you to setup." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Automatic sync" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Automatically mark articles as read" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Cancel" : {
|
"Cancel" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Clear cache" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Clipboard" : {
|
"Clipboard" : {
|
||||||
|
|
||||||
@ -79,6 +91,9 @@
|
|||||||
},
|
},
|
||||||
"Current labels" : {
|
"Current labels" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Data Management" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Delete" : {
|
"Delete" : {
|
||||||
|
|
||||||
@ -91,6 +106,9 @@
|
|||||||
},
|
},
|
||||||
"e.g. work, important, later" : {
|
"e.g. work, important, later" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Enter an optional title..." : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Enter label..." : {
|
"Enter label..." : {
|
||||||
|
|
||||||
@ -184,6 +202,9 @@
|
|||||||
},
|
},
|
||||||
"OK" : {
|
"OK" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Open external links in in-app Safari" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Optional: Custom title" : {
|
"Optional: Custom title" : {
|
||||||
|
|
||||||
@ -224,21 +245,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"Reading Settings" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Remove" : {
|
"Remove" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Required" : {
|
"Required" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Reset settings" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Restore" : {
|
"Restore" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Resume listening" : {
|
"Resume listening" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Safari Reader Mode" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Save bookmark" : {
|
"Save bookmark" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Save Bookmark" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Saving..." : {
|
"Saving..." : {
|
||||||
|
|
||||||
@ -266,6 +299,12 @@
|
|||||||
},
|
},
|
||||||
"Suche..." : {
|
"Suche..." : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Sync interval" : {
|
||||||
|
|
||||||
|
},
|
||||||
|
"Sync Settings" : {
|
||||||
|
|
||||||
},
|
},
|
||||||
"Theme" : {
|
"Theme" : {
|
||||||
|
|
||||||
|
|||||||
121
URLShare/ShareBookmarkView.swift
Normal file
121
URLShare/ShareBookmarkView.swift
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ShareBookmarkView: View {
|
||||||
|
@ObservedObject var viewModel: ShareBookmarkViewModel
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Logo
|
||||||
|
Image("readeck")
|
||||||
|
.resizable()
|
||||||
|
.scaledToFit()
|
||||||
|
.frame(height: 40)
|
||||||
|
.padding(.top, 24)
|
||||||
|
.opacity(0.9)
|
||||||
|
// URL
|
||||||
|
if let url = viewModel.url {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "link")
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
Text(url)
|
||||||
|
.font(.system(size: 15, weight: .bold, design: .default))
|
||||||
|
.foregroundColor(.accentColor)
|
||||||
|
.lineLimit(2)
|
||||||
|
.truncationMode(.middle)
|
||||||
|
}
|
||||||
|
.padding(.top, 8)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
}
|
||||||
|
// Titel
|
||||||
|
TextField("Enter an optional title...", text: $viewModel.title)
|
||||||
|
.font(.system(size: 17, weight: .medium))
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.frame(height: 38)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
|
.fill(Color(.secondarySystemGroupedBackground))
|
||||||
|
.shadow(color: Color.black.opacity(0.04), radius: 2, x: 0, y: 1)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 12, style: .continuous)
|
||||||
|
.stroke(Color.accentColor.opacity(viewModel.title.isEmpty ? 0.12 : 0.7), lineWidth: viewModel.title.isEmpty ? 1 : 2)
|
||||||
|
)
|
||||||
|
.padding(.top, 20)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.frame(maxWidth: 420)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .center)
|
||||||
|
|
||||||
|
// Label Grid
|
||||||
|
if !viewModel.labels.isEmpty {
|
||||||
|
LabelGridView(labels: viewModel.labels, selected: $viewModel.selectedLabels)
|
||||||
|
.padding(.top, 32)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.frame(minHeight: 100)
|
||||||
|
}
|
||||||
|
// Status
|
||||||
|
if let status = viewModel.statusMessage {
|
||||||
|
Text(status.emoji + " " + status.text)
|
||||||
|
.font(.system(size: 18, weight: .bold))
|
||||||
|
.foregroundColor(status.isError ? .red : .green)
|
||||||
|
.padding(.top, 32)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
// Save Button
|
||||||
|
Button(action: { viewModel.save() }) {
|
||||||
|
if viewModel.isSaving {
|
||||||
|
ProgressView()
|
||||||
|
.progressViewStyle(CircularProgressViewStyle())
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding()
|
||||||
|
} else {
|
||||||
|
Text("Save Bookmark")
|
||||||
|
.font(.system(size: 17, weight: .semibold))
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding()
|
||||||
|
.background(Color.accentColor)
|
||||||
|
.foregroundColor(.white)
|
||||||
|
.cornerRadius(16)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.bottom, 32)
|
||||||
|
.disabled(viewModel.isSaving)
|
||||||
|
}
|
||||||
|
.background(Color(.systemGroupedBackground))
|
||||||
|
.onAppear { viewModel.onAppear() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct LabelGridView: View {
|
||||||
|
let labels: [BookmarkLabelDto]
|
||||||
|
@Binding var selected: Set<String>
|
||||||
|
private let columns = [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())]
|
||||||
|
var body: some View {
|
||||||
|
LazyVGrid(columns: columns, spacing: 12) {
|
||||||
|
ForEach(labels.prefix(15), id: \ .name) { label in
|
||||||
|
Button(action: {
|
||||||
|
if selected.contains(label.name) {
|
||||||
|
selected.remove(label.name)
|
||||||
|
} else {
|
||||||
|
selected.insert(label.name)
|
||||||
|
}
|
||||||
|
}) {
|
||||||
|
Text(label.name)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.background(selected.contains(label.name) ? Color.accentColor.opacity(0.2) : Color(.secondarySystemGroupedBackground))
|
||||||
|
.foregroundColor(.primary)
|
||||||
|
.cornerRadius(8)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 8)
|
||||||
|
.stroke(selected.contains(label.name) ? Color.accentColor : Color.clear, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
80
URLShare/ShareBookmarkViewModel.swift
Normal file
80
URLShare/ShareBookmarkViewModel.swift
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftUI
|
||||||
|
import UniformTypeIdentifiers
|
||||||
|
|
||||||
|
class ShareBookmarkViewModel: ObservableObject {
|
||||||
|
@Published var url: String?
|
||||||
|
@Published var title: String = ""
|
||||||
|
@Published var labels: [BookmarkLabelDto] = []
|
||||||
|
@Published var selectedLabels: Set<String> = []
|
||||||
|
@Published var statusMessage: (text: String, isError: Bool, emoji: String)? = nil
|
||||||
|
@Published var isSaving: Bool = false
|
||||||
|
private weak var extensionContext: NSExtensionContext?
|
||||||
|
|
||||||
|
init(extensionContext: NSExtensionContext?) {
|
||||||
|
self.extensionContext = extensionContext
|
||||||
|
extractSharedContent()
|
||||||
|
}
|
||||||
|
|
||||||
|
func onAppear() {
|
||||||
|
loadLabels()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func extractSharedContent() {
|
||||||
|
guard let extensionContext = extensionContext else { return }
|
||||||
|
for item in extensionContext.inputItems {
|
||||||
|
guard let inputItem = item as? NSExtensionItem else { continue }
|
||||||
|
for attachment in inputItem.attachments ?? [] {
|
||||||
|
if attachment.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
|
||||||
|
attachment.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { [weak self] (url, error) in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if let url = url as? URL {
|
||||||
|
self?.url = url.absoluteString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if attachment.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
|
||||||
|
attachment.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { [weak self] (text, error) in
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
if let text = text as? String, let url = URL(string: text) {
|
||||||
|
self?.url = url.absoluteString
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadLabels() {
|
||||||
|
Task {
|
||||||
|
let loaded = await SimpleAPI.getBookmarkLabels { [weak self] message, error in
|
||||||
|
self?.statusMessage = (message, error, error ? "❌" : "✅")
|
||||||
|
} ?? []
|
||||||
|
let sorted = loaded.prefix(10).sorted { $0.count > $1.count }
|
||||||
|
await MainActor.run {
|
||||||
|
self.labels = Array(sorted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func save() {
|
||||||
|
guard let url = url, !url.isEmpty else {
|
||||||
|
statusMessage = ("No URL found.", true, "❌")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
isSaving = true
|
||||||
|
Task {
|
||||||
|
await SimpleAPI.addBookmark(title: title, url: url, labels: Array(selectedLabels)) { [weak self] message, error in
|
||||||
|
self?.statusMessage = (message, error, error ? "❌" : "✅")
|
||||||
|
self?.isSaving = false
|
||||||
|
if !error {
|
||||||
|
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
||||||
|
self?.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,355 +8,27 @@
|
|||||||
import UIKit
|
import UIKit
|
||||||
import Social
|
import Social
|
||||||
import UniformTypeIdentifiers
|
import UniformTypeIdentifiers
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
class ShareViewController: UIViewController {
|
class ShareViewController: UIViewController {
|
||||||
|
|
||||||
private var extractedURL: String?
|
private var hostingController: UIHostingController<ShareBookmarkView>?
|
||||||
private var extractedTitle: String?
|
|
||||||
|
|
||||||
// UI Elements
|
|
||||||
private var titleTextField: UITextField?
|
|
||||||
private var urlLabel: UILabel?
|
|
||||||
private var statusLabel: UILabel?
|
|
||||||
private var saveButton: UIButton?
|
|
||||||
private var activityIndicator: UIActivityIndicatorView?
|
|
||||||
|
|
||||||
override func viewDidLoad() {
|
override func viewDidLoad() {
|
||||||
super.viewDidLoad()
|
super.viewDidLoad()
|
||||||
setupUI()
|
let viewModel = ShareBookmarkViewModel(extensionContext: extensionContext)
|
||||||
extractSharedContent()
|
let swiftUIView = ShareBookmarkView(viewModel: viewModel)
|
||||||
}
|
let hostingController = UIHostingController(rootView: swiftUIView)
|
||||||
|
addChild(hostingController)
|
||||||
|
hostingController.view.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
view.addSubview(hostingController.view)
|
||||||
// MARK: - UI Setup
|
|
||||||
private func setupUI() {
|
|
||||||
view.backgroundColor = UIColor(named: "green") ?? UIColor.systemGroupedBackground
|
|
||||||
|
|
||||||
// Add cancel button
|
|
||||||
let cancelButton = UIBarButtonItem(title: "Cancel", style: .plain, target: self, action: #selector(cancelButtonTapped))
|
|
||||||
cancelButton.tintColor = UIColor.white
|
|
||||||
navigationItem.leftBarButtonItem = cancelButton
|
|
||||||
|
|
||||||
// Ensure navigation bar is visible
|
|
||||||
navigationController?.navigationBar.isTranslucent = false
|
|
||||||
navigationController?.navigationBar.backgroundColor = UIColor(named: "green") ?? UIColor.systemGreen
|
|
||||||
navigationController?.navigationBar.tintColor = UIColor.white
|
|
||||||
navigationController?.navigationBar.titleTextAttributes = [NSAttributedString.Key.foregroundColor: UIColor.white]
|
|
||||||
|
|
||||||
// Add logo
|
|
||||||
let logoImageView = UIImageView(image: UIImage(named: "readeck"))
|
|
||||||
logoImageView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
logoImageView.contentMode = .scaleAspectFit
|
|
||||||
logoImageView.alpha = 0.9
|
|
||||||
view.addSubview(logoImageView)
|
|
||||||
|
|
||||||
// Add custom cancel button
|
|
||||||
let customCancelButton = UIButton(type: .system)
|
|
||||||
customCancelButton.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
customCancelButton.setTitle("Cancel", for: .normal)
|
|
||||||
customCancelButton.setTitleColor(UIColor.white, for: .normal)
|
|
||||||
customCancelButton.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .medium)
|
|
||||||
customCancelButton.addTarget(self, action: #selector(cancelButtonTapped), for: .touchUpInside)
|
|
||||||
view.addSubview(customCancelButton)
|
|
||||||
|
|
||||||
// URL Container View
|
|
||||||
let urlContainerView = UIView()
|
|
||||||
urlContainerView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
urlContainerView.backgroundColor = UIColor.secondarySystemGroupedBackground
|
|
||||||
urlContainerView.layer.cornerRadius = 12
|
|
||||||
urlContainerView.layer.shadowColor = UIColor.black.cgColor
|
|
||||||
urlContainerView.layer.shadowOffset = CGSize(width: 0, height: 2)
|
|
||||||
urlContainerView.layer.shadowRadius = 4
|
|
||||||
urlContainerView.layer.shadowOpacity = 0.1
|
|
||||||
view.addSubview(urlContainerView)
|
|
||||||
|
|
||||||
// URL Label
|
|
||||||
urlLabel = UILabel()
|
|
||||||
urlLabel?.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
urlLabel?.font = UIFont.systemFont(ofSize: 15, weight: .medium)
|
|
||||||
urlLabel?.textColor = UIColor.label
|
|
||||||
urlLabel?.numberOfLines = 0
|
|
||||||
urlLabel?.text = "Loading URL..."
|
|
||||||
urlLabel?.textAlignment = .left
|
|
||||||
urlContainerView.addSubview(urlLabel!)
|
|
||||||
|
|
||||||
// Title Container View
|
|
||||||
let titleContainerView = UIView()
|
|
||||||
titleContainerView.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
titleContainerView.backgroundColor = UIColor.secondarySystemGroupedBackground
|
|
||||||
titleContainerView.layer.cornerRadius = 12
|
|
||||||
titleContainerView.layer.shadowColor = UIColor.black.cgColor
|
|
||||||
titleContainerView.layer.shadowOffset = CGSize(width: 0, height: 2)
|
|
||||||
titleContainerView.layer.shadowRadius = 4
|
|
||||||
titleContainerView.layer.shadowOpacity = 0.1
|
|
||||||
view.addSubview(titleContainerView)
|
|
||||||
|
|
||||||
// Title TextField
|
|
||||||
titleTextField = UITextField()
|
|
||||||
titleTextField?.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
titleTextField?.placeholder = "Enter an optional title..."
|
|
||||||
titleTextField?.borderStyle = .none
|
|
||||||
titleTextField?.font = UIFont.systemFont(ofSize: 16)
|
|
||||||
titleTextField?.backgroundColor = UIColor.clear
|
|
||||||
titleContainerView.addSubview(titleTextField!)
|
|
||||||
|
|
||||||
// Status Label
|
|
||||||
statusLabel = UILabel()
|
|
||||||
statusLabel?.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
statusLabel?.font = UIFont.systemFont(ofSize: 14, weight: .medium)
|
|
||||||
statusLabel?.numberOfLines = 0
|
|
||||||
statusLabel?.textAlignment = .center
|
|
||||||
statusLabel?.isHidden = true
|
|
||||||
statusLabel?.layer.cornerRadius = 10
|
|
||||||
statusLabel?.layer.masksToBounds = true
|
|
||||||
view.addSubview(statusLabel!)
|
|
||||||
|
|
||||||
let isDarkMode = traitCollection.userInterfaceStyle == .dark
|
|
||||||
|
|
||||||
// Save Button
|
|
||||||
saveButton = UIButton(type: .system)
|
|
||||||
saveButton?.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
saveButton?.setTitle("Save Bookmark", for: .normal)
|
|
||||||
saveButton?.titleLabel?.font = UIFont.systemFont(ofSize: 17, weight: .semibold)
|
|
||||||
|
|
||||||
if isDarkMode {
|
|
||||||
saveButton?.backgroundColor = UIColor(named: "green")
|
|
||||||
saveButton?.layer.borderColor = UIColor(named: "green")?.cgColor
|
|
||||||
} else {
|
|
||||||
saveButton?.backgroundColor = .accent
|
|
||||||
saveButton?.setTitleColor(UIColor(named: "green") ?? UIColor.systemGreen, for: .normal)
|
|
||||||
}
|
|
||||||
|
|
||||||
saveButton?.layer.cornerRadius = 16
|
|
||||||
saveButton?.layer.shadowColor = UIColor.black.cgColor
|
|
||||||
saveButton?.layer.shadowOffset = CGSize(width: 0, height: 4)
|
|
||||||
saveButton?.layer.shadowRadius = 8
|
|
||||||
saveButton?.layer.shadowOpacity = 0.2
|
|
||||||
saveButton?.addTarget(self, action: #selector(saveButtonTapped), for: .touchUpInside)
|
|
||||||
view.addSubview(saveButton!)
|
|
||||||
|
|
||||||
// Activity Indicator
|
|
||||||
activityIndicator = UIActivityIndicatorView(style: .medium)
|
|
||||||
activityIndicator?.translatesAutoresizingMaskIntoConstraints = false
|
|
||||||
activityIndicator?.hidesWhenStopped = true
|
|
||||||
view.addSubview(activityIndicator!)
|
|
||||||
|
|
||||||
setupConstraints()
|
|
||||||
}
|
|
||||||
|
|
||||||
private func setupConstraints() {
|
|
||||||
guard let urlLabel = urlLabel,
|
|
||||||
let titleTextField = titleTextField,
|
|
||||||
let statusLabel = statusLabel,
|
|
||||||
let saveButton = saveButton,
|
|
||||||
let activityIndicator = activityIndicator else { return }
|
|
||||||
|
|
||||||
// Find container views and logo
|
|
||||||
let urlContainerView = urlLabel.superview!
|
|
||||||
let titleContainerView = titleTextField.superview!
|
|
||||||
let logoImageView = view.subviews.first { $0 is UIImageView }!
|
|
||||||
let customCancelButton = view.subviews.first { $0 is UIButton && $0 != saveButton }!
|
|
||||||
|
|
||||||
NSLayoutConstraint.activate([
|
NSLayoutConstraint.activate([
|
||||||
// Custom Cancel Button
|
hostingController.view.topAnchor.constraint(equalTo: view.topAnchor),
|
||||||
customCancelButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
|
hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
||||||
customCancelButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
|
hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||||
customCancelButton.heightAnchor.constraint(equalToConstant: 36),
|
hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor)
|
||||||
customCancelButton.widthAnchor.constraint(equalToConstant: 100),
|
|
||||||
|
|
||||||
// Logo
|
|
||||||
logoImageView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20),
|
|
||||||
logoImageView.centerXAnchor.constraint(equalTo: view.centerXAnchor),
|
|
||||||
logoImageView.heightAnchor.constraint(equalToConstant: 40),
|
|
||||||
logoImageView.widthAnchor.constraint(equalToConstant: 120),
|
|
||||||
|
|
||||||
// URL Container
|
|
||||||
urlContainerView.topAnchor.constraint(equalTo: logoImageView.bottomAnchor, constant: 24),
|
|
||||||
urlContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
|
|
||||||
urlContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
|
|
||||||
|
|
||||||
// URL Label inside container
|
|
||||||
urlLabel.topAnchor.constraint(equalTo: urlContainerView.topAnchor, constant: 16),
|
|
||||||
urlLabel.leadingAnchor.constraint(equalTo: urlContainerView.leadingAnchor, constant: 16),
|
|
||||||
urlLabel.trailingAnchor.constraint(equalTo: urlContainerView.trailingAnchor, constant: -16),
|
|
||||||
urlLabel.bottomAnchor.constraint(equalTo: urlContainerView.bottomAnchor, constant: -16),
|
|
||||||
|
|
||||||
// Title Container
|
|
||||||
titleContainerView.topAnchor.constraint(equalTo: urlContainerView.bottomAnchor, constant: 20),
|
|
||||||
titleContainerView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
|
|
||||||
titleContainerView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
|
|
||||||
titleContainerView.heightAnchor.constraint(equalToConstant: 60),
|
|
||||||
|
|
||||||
// Title TextField inside container
|
|
||||||
titleTextField.topAnchor.constraint(equalTo: titleContainerView.topAnchor, constant: 16),
|
|
||||||
titleTextField.leadingAnchor.constraint(equalTo: titleContainerView.leadingAnchor, constant: 16),
|
|
||||||
titleTextField.trailingAnchor.constraint(equalTo: titleContainerView.trailingAnchor, constant: -16),
|
|
||||||
titleTextField.bottomAnchor.constraint(equalTo: titleContainerView.bottomAnchor, constant: -16),
|
|
||||||
|
|
||||||
// Status Label
|
|
||||||
statusLabel.topAnchor.constraint(equalTo: titleContainerView.bottomAnchor, constant: 20),
|
|
||||||
statusLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
|
|
||||||
statusLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
|
|
||||||
|
|
||||||
// Save Button
|
|
||||||
saveButton.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 32),
|
|
||||||
saveButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
|
|
||||||
saveButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16),
|
|
||||||
saveButton.heightAnchor.constraint(equalToConstant: 56),
|
|
||||||
|
|
||||||
// Activity Indicator
|
|
||||||
activityIndicator.centerXAnchor.constraint(equalTo: saveButton.centerXAnchor),
|
|
||||||
activityIndicator.centerYAnchor.constraint(equalTo: saveButton.centerYAnchor)
|
|
||||||
])
|
])
|
||||||
}
|
hostingController.didMove(toParent: self)
|
||||||
|
self.hostingController = hostingController
|
||||||
// MARK: - Content Extraction
|
|
||||||
private func extractSharedContent() {
|
|
||||||
guard let extensionContext = extensionContext else { return }
|
|
||||||
|
|
||||||
for item in extensionContext.inputItems {
|
|
||||||
guard let inputItem = item as? NSExtensionItem else { continue }
|
|
||||||
|
|
||||||
for attachment in inputItem.attachments ?? [] {
|
|
||||||
if attachment.hasItemConformingToTypeIdentifier(UTType.url.identifier) {
|
|
||||||
attachment.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { [weak self] (url, error) in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
if let url = url as? URL {
|
|
||||||
self?.extractedURL = url.absoluteString
|
|
||||||
self?.urlLabel?.text = url.absoluteString
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if attachment.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) {
|
|
||||||
attachment.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { [weak self] (text, error) in
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
if let text = text as? String, let url = URL(string: text) {
|
|
||||||
self?.extractedURL = url.absoluteString
|
|
||||||
self?.urlLabel?.text = url.absoluteString
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Actions
|
|
||||||
|
|
||||||
@objc private func saveButtonTapped() {
|
|
||||||
let title = titleTextField?.text ?? ""
|
|
||||||
|
|
||||||
saveButton?.isEnabled = false
|
|
||||||
activityIndicator?.startAnimating()
|
|
||||||
|
|
||||||
Task {
|
|
||||||
await addBookmarkViaAPI(title: title)
|
|
||||||
await MainActor.run {
|
|
||||||
self.saveButton?.isEnabled = true
|
|
||||||
self.activityIndicator?.stopAnimating()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func cancelButtonTapped() {
|
|
||||||
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - API Call
|
|
||||||
private func addBookmarkViaAPI(title: String) async {
|
|
||||||
guard let url = extractedURL, !url.isEmpty else {
|
|
||||||
showStatus("No URL found.", error: true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Token und Endpoint aus KeychainHelper
|
|
||||||
guard let token = KeychainHelper.shared.loadToken() else {
|
|
||||||
showStatus("No token found. Please log in via the main app.", error: true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let endpoint = KeychainHelper.shared.loadEndpoint(), !endpoint.isEmpty else {
|
|
||||||
showStatus("No server endpoint found.", error: true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let requestDto = CreateBookmarkRequestDto(url: url, title: title, labels: [])
|
|
||||||
guard let requestData = try? JSONEncoder().encode(requestDto) else {
|
|
||||||
showStatus("Failed to encode request.", error: true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard let apiUrl = URL(string: endpoint + "/api/bookmarks") else {
|
|
||||||
showStatus("Invalid server endpoint.", error: true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
var request = URLRequest(url: apiUrl)
|
|
||||||
request.httpMethod = "POST"
|
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
|
||||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
|
||||||
request.httpBody = requestData
|
|
||||||
|
|
||||||
do {
|
|
||||||
let (data, response) = try await URLSession.shared.data(for: request)
|
|
||||||
|
|
||||||
guard let httpResponse = response as? HTTPURLResponse else {
|
|
||||||
showStatus("Invalid server response.", error: true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
guard 200...299 ~= httpResponse.statusCode else {
|
|
||||||
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
|
|
||||||
showStatus("Server error: \(httpResponse.statusCode)\n\(msg)", error: true)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Optional: Response parsen
|
|
||||||
if let resp = try? JSONDecoder().decode(CreateBookmarkResponseDto.self, from: data) {
|
|
||||||
showStatus("Saved: \(resp.message)", error: false)
|
|
||||||
} else {
|
|
||||||
showStatus("Bookmark saved!", error: false)
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
showStatus("Network error: \(error.localizedDescription)", error: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private func showStatus(_ message: String, error: Bool) {
|
|
||||||
DispatchQueue.main.async {
|
|
||||||
self.statusLabel?.text = message
|
|
||||||
self.statusLabel?.textColor = error ? UIColor.systemRed : UIColor.systemGreen
|
|
||||||
self.statusLabel?.backgroundColor = error ? UIColor.systemRed.withAlphaComponent(0.1) : UIColor.systemGreen.withAlphaComponent(0.1)
|
|
||||||
self.statusLabel?.isHidden = false
|
|
||||||
|
|
||||||
if !error {
|
|
||||||
// Automatically dismiss after success
|
|
||||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
|
|
||||||
self.extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
// MARK: - DTOs (copied)
|
|
||||||
private struct CreateBookmarkRequestDto: Codable {
|
|
||||||
let labels: [String]?
|
|
||||||
let title: String?
|
|
||||||
let url: String
|
|
||||||
|
|
||||||
init(url: String, title: String? = nil, labels: [String]? = nil) {
|
|
||||||
self.url = url
|
|
||||||
self.title = title
|
|
||||||
self.labels = labels
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private struct CreateBookmarkResponseDto: Codable {
|
|
||||||
let message: String
|
|
||||||
let status: Int
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
84
URLShare/SimpleAPI.swift
Normal file
84
URLShare/SimpleAPI.swift
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
class SimpleAPI {
|
||||||
|
// MARK: - API Methods
|
||||||
|
static func addBookmark(title: String, url: String, labels: [String]? = nil, showStatus: @escaping (String, Bool) -> Void) async {
|
||||||
|
guard let token = KeychainHelper.shared.loadToken() else {
|
||||||
|
showStatus("No token found. Please log in via the main app.", true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let endpoint = KeychainHelper.shared.loadEndpoint(), !endpoint.isEmpty else {
|
||||||
|
showStatus("No server endpoint found.", true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let requestDto = CreateBookmarkRequestDto(url: url, title: title, labels: labels)
|
||||||
|
guard let requestData = try? JSONEncoder().encode(requestDto) else {
|
||||||
|
showStatus("Failed to encode request.", true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let apiUrl = URL(string: endpoint + "/api/bookmarks") else {
|
||||||
|
showStatus("Invalid server endpoint.", true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var request = URLRequest(url: apiUrl)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
request.httpBody = requestData
|
||||||
|
do {
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
showStatus("Invalid server response.", true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard 200...299 ~= httpResponse.statusCode else {
|
||||||
|
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
|
||||||
|
showStatus("Server error: \(httpResponse.statusCode)\n\(msg)", true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if let resp = try? JSONDecoder().decode(CreateBookmarkResponseDto.self, from: data) {
|
||||||
|
showStatus("Saved: \(resp.message)", false)
|
||||||
|
} else {
|
||||||
|
showStatus("Bookmark saved!", false)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
showStatus("Network error: \(error.localizedDescription)", true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static func getBookmarkLabels(showStatus: @escaping (String, Bool) -> Void) async -> [BookmarkLabelDto]? {
|
||||||
|
guard let token = KeychainHelper.shared.loadToken() else {
|
||||||
|
showStatus("No token found. Please log in via the main app.", true)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let endpoint = KeychainHelper.shared.loadEndpoint(), !endpoint.isEmpty else {
|
||||||
|
showStatus("No server endpoint found.", true)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let apiUrl = URL(string: endpoint + "/api/bookmarks/labels") else {
|
||||||
|
showStatus("Invalid server endpoint.", true)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var request = URLRequest(url: apiUrl)
|
||||||
|
request.httpMethod = "GET"
|
||||||
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
|
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||||
|
do {
|
||||||
|
let (data, response) = try await URLSession.shared.data(for: request)
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
|
showStatus("Invalid server response.", true)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard 200...299 ~= httpResponse.statusCode else {
|
||||||
|
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
|
||||||
|
showStatus("Server error: \(httpResponse.statusCode)\n\(msg)", true)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
let labels = try JSONDecoder().decode([BookmarkLabelDto].self, from: data)
|
||||||
|
return labels
|
||||||
|
} catch {
|
||||||
|
showStatus("Network error: \(error.localizedDescription)", true)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
36
URLShare/SimpleAPIDTOs.swift
Normal file
36
URLShare/SimpleAPIDTOs.swift
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public struct CreateBookmarkRequestDto: Codable {
|
||||||
|
public let labels: [String]?
|
||||||
|
public let title: String?
|
||||||
|
public let url: String
|
||||||
|
|
||||||
|
public init(url: String, title: String? = nil, labels: [String]? = nil) {
|
||||||
|
self.url = url
|
||||||
|
self.title = title
|
||||||
|
self.labels = labels
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct CreateBookmarkResponseDto: Codable {
|
||||||
|
public let message: String
|
||||||
|
public let status: Int
|
||||||
|
}
|
||||||
|
|
||||||
|
public struct BookmarkLabelDto: Codable, Identifiable {
|
||||||
|
public var id: String { href }
|
||||||
|
public let name: String
|
||||||
|
public let count: Int
|
||||||
|
public let href: String
|
||||||
|
|
||||||
|
public enum CodingKeys: String, CodingKey {
|
||||||
|
case name, count, href
|
||||||
|
}
|
||||||
|
|
||||||
|
public init(name: String, count: Int, href: String) {
|
||||||
|
self.name = name
|
||||||
|
self.count = count
|
||||||
|
self.href = href
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -609,7 +609,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 5;
|
CURRENT_PROJECT_VERSION = 8;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
@ -653,7 +653,7 @@
|
|||||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||||
CODE_SIGN_IDENTITY = "Apple Development";
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 5;
|
CURRENT_PROJECT_VERSION = 8;
|
||||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||||
DEVELOPMENT_TEAM = 8J69P655GN;
|
DEVELOPMENT_TEAM = 8J69P655GN;
|
||||||
ENABLE_HARDENED_RUNTIME = YES;
|
ENABLE_HARDENED_RUNTIME = YES;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user