From bdd7d234a9516b676d3d7a1ad9d89654a4b6b963 Mon Sep 17 00:00:00 2001 From: Ilyas Hallak Date: Tue, 22 Jul 2025 21:14:42 +0200 Subject: [PATCH] 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 --- Localizable.xcstrings | 39 +++ URLShare/ShareBookmarkView.swift | 121 +++++++++ URLShare/ShareBookmarkViewModel.swift | 80 ++++++ URLShare/ShareViewController.swift | 356 +------------------------- URLShare/SimpleAPI.swift | 84 ++++++ URLShare/SimpleAPIDTOs.swift | 36 +++ readeck.xcodeproj/project.pbxproj | 4 +- 7 files changed, 376 insertions(+), 344 deletions(-) create mode 100644 URLShare/ShareBookmarkView.swift create mode 100644 URLShare/ShareBookmarkViewModel.swift create mode 100644 URLShare/SimpleAPI.swift create mode 100644 URLShare/SimpleAPIDTOs.swift diff --git a/Localizable.xcstrings b/Localizable.xcstrings index fcdaad6..085232a 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -22,6 +22,9 @@ }, "%lld min" : { + }, + "%lld minutes" : { + }, "%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." : { + }, + "Automatic sync" : { + + }, + "Automatically mark articles as read" : { + }, "Cancel" : { + }, + "Clear cache" : { + }, "Clipboard" : { @@ -79,6 +91,9 @@ }, "Current labels" : { + }, + "Data Management" : { + }, "Delete" : { @@ -91,6 +106,9 @@ }, "e.g. work, important, later" : { + }, + "Enter an optional title..." : { + }, "Enter label..." : { @@ -184,6 +202,9 @@ }, "OK" : { + }, + "Open external links in in-app Safari" : { + }, "Optional: Custom title" : { @@ -224,21 +245,33 @@ } } } + }, + "Reading Settings" : { + }, "Remove" : { }, "Required" : { + }, + "Reset settings" : { + }, "Restore" : { }, "Resume listening" : { + }, + "Safari Reader Mode" : { + }, "Save bookmark" : { + }, + "Save Bookmark" : { + }, "Saving..." : { @@ -266,6 +299,12 @@ }, "Suche..." : { + }, + "Sync interval" : { + + }, + "Sync Settings" : { + }, "Theme" : { diff --git a/URLShare/ShareBookmarkView.swift b/URLShare/ShareBookmarkView.swift new file mode 100644 index 0000000..c20b7e5 --- /dev/null +++ b/URLShare/ShareBookmarkView.swift @@ -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 + 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) + ) + } + } + } + } +} diff --git a/URLShare/ShareBookmarkViewModel.swift b/URLShare/ShareBookmarkViewModel.swift new file mode 100644 index 0000000..b01f36f --- /dev/null +++ b/URLShare/ShareBookmarkViewModel.swift @@ -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 = [] + @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) + } + } + } + } + } +} diff --git a/URLShare/ShareViewController.swift b/URLShare/ShareViewController.swift index 8e5d301..0539756 100644 --- a/URLShare/ShareViewController.swift +++ b/URLShare/ShareViewController.swift @@ -8,355 +8,27 @@ import UIKit import Social import UniformTypeIdentifiers +import SwiftUI 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? + private var hostingController: UIHostingController? 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: "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 }! - + let viewModel = ShareBookmarkViewModel(extensionContext: extensionContext) + let swiftUIView = ShareBookmarkView(viewModel: viewModel) + let hostingController = UIHostingController(rootView: swiftUIView) + addChild(hostingController) + hostingController.view.translatesAutoresizingMaskIntoConstraints = false + view.addSubview(hostingController.view) 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) + hostingController.view.topAnchor.constraint(equalTo: view.topAnchor), + hostingController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + hostingController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + hostingController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor) ]) - } - - // 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 + hostingController.didMove(toParent: self) + self.hostingController = hostingController } } diff --git a/URLShare/SimpleAPI.swift b/URLShare/SimpleAPI.swift new file mode 100644 index 0000000..a11e6eb --- /dev/null +++ b/URLShare/SimpleAPI.swift @@ -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 + } + } +} diff --git a/URLShare/SimpleAPIDTOs.swift b/URLShare/SimpleAPIDTOs.swift new file mode 100644 index 0000000..65e2d06 --- /dev/null +++ b/URLShare/SimpleAPIDTOs.swift @@ -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 + } +} + diff --git a/readeck.xcodeproj/project.pbxproj b/readeck.xcodeproj/project.pbxproj index 3a19efc..765a2ee 100644 --- a/readeck.xcodeproj/project.pbxproj +++ b/readeck.xcodeproj/project.pbxproj @@ -609,7 +609,7 @@ CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 8; DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\""; DEVELOPMENT_TEAM = 8J69P655GN; ENABLE_HARDENED_RUNTIME = YES; @@ -653,7 +653,7 @@ CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 5; + CURRENT_PROJECT_VERSION = 8; DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\""; DEVELOPMENT_TEAM = 8J69P655GN; ENABLE_HARDENED_RUNTIME = YES;