- Add comprehensive German localization with Localizable.xcstrings - Integrate R.swift for type-safe resource management - Improve share extension UI with better styling and optional title input - Add archive functionality to bookmark detail view - Update README with current features and planned roadmap - Remove title validation requirement from share extension - Optimize share extension auto-dismiss timing - Clean up code structure and remove unused components
357 lines
16 KiB
Swift
357 lines
16 KiB
Swift
//
|
|
// ShareViewController.swift
|
|
// URLShare
|
|
//
|
|
// Created by Ilyas Hallak on 11.06.25.
|
|
//
|
|
|
|
import UIKit
|
|
import Social
|
|
import UniformTypeIdentifiers
|
|
|
|
class ShareViewController: UIViewController {
|
|
|
|
private var extractedURL: String?
|
|
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() {
|
|
super.viewDidLoad()
|
|
setupUI()
|
|
extractSharedContent()
|
|
}
|
|
|
|
|
|
|
|
// MARK: - UI Setup
|
|
private func setupUI() {
|
|
view.backgroundColor = UIColor(named: "green") ?? UIColor.systemGroupedBackground
|
|
|
|
// Add cancel button
|
|
let cancelButton = UIBarButtonItem(title: "Abbrechen", 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("Abbrechen", 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 = "URL wird geladen..."
|
|
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 = "Optionales Titel eingeben..."
|
|
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!)
|
|
|
|
// Save Button
|
|
saveButton = UIButton(type: .system)
|
|
saveButton?.translatesAutoresizingMaskIntoConstraints = false
|
|
saveButton?.setTitle("Bookmark speichern", for: .normal)
|
|
saveButton?.titleLabel?.font = UIFont.systemFont(ofSize: 17, weight: .semibold)
|
|
saveButton?.backgroundColor = UIColor.secondarySystemGroupedBackground
|
|
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([
|
|
// Custom Cancel Button
|
|
customCancelButton.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 16),
|
|
customCancelButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16),
|
|
customCancelButton.heightAnchor.constraint(equalToConstant: 36),
|
|
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)
|
|
])
|
|
}
|
|
|
|
// 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("Keine URL gefunden.", error: true)
|
|
return
|
|
}
|
|
|
|
// Token und Endpoint aus KeychainHelper
|
|
guard let token = KeychainHelper.shared.loadToken() else {
|
|
showStatus("Kein Token gefunden. Bitte in der Haupt-App einloggen.", error: true)
|
|
return
|
|
}
|
|
|
|
guard let endpoint = KeychainHelper.shared.loadEndpoint(), !endpoint.isEmpty else {
|
|
showStatus("Kein Server-Endpunkt gefunden.", error: true)
|
|
return
|
|
}
|
|
|
|
let requestDto = CreateBookmarkRequestDto(url: url, title: title, labels: [])
|
|
guard let requestData = try? JSONEncoder().encode(requestDto) else {
|
|
showStatus("Fehler beim Kodieren der Anfrage.", error: true)
|
|
return
|
|
}
|
|
|
|
guard let apiUrl = URL(string: endpoint + "/api/bookmarks") else {
|
|
showStatus("Ungültiger Server-Endpunkt.", 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("Ungültige Server-Antwort.", error: true)
|
|
return
|
|
}
|
|
|
|
guard 200...299 ~= httpResponse.statusCode else {
|
|
let msg = String(data: data, encoding: .utf8) ?? "Unbekannter Fehler"
|
|
showStatus("Serverfehler: \(httpResponse.statusCode)\n\(msg)", error: true)
|
|
return
|
|
}
|
|
|
|
// Optional: Response parsen
|
|
if let resp = try? JSONDecoder().decode(CreateBookmarkResponseDto.self, from: data) {
|
|
showStatus("Gespeichert: \(resp.message)", error: false)
|
|
} else {
|
|
showStatus("Lesezeichen gespeichert!", error: false)
|
|
}
|
|
} catch {
|
|
showStatus("Netzwerkfehler: \(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 (kopiert)
|
|
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
|
|
}
|
|
}
|