diff --git a/URLShare/Base.lproj/MainInterface.storyboard b/URLShare/Base.lproj/MainInterface.storyboard new file mode 100644 index 0000000..286a508 --- /dev/null +++ b/URLShare/Base.lproj/MainInterface.storyboard @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/URLShare/Info.plist b/URLShare/Info.plist new file mode 100644 index 0000000..36d4d3e --- /dev/null +++ b/URLShare/Info.plist @@ -0,0 +1,21 @@ + + + + + NSExtension + + NSExtensionAttributes + + NSExtensionActivationRule + + NSExtensionActivationSupportsWebURLWithMaxCount + 1 + + + NSExtensionPointIdentifier + com.apple.share-services + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ShareViewController + + + diff --git a/URLShare/ShareViewController.swift b/URLShare/ShareViewController.swift new file mode 100644 index 0000000..2a8de35 --- /dev/null +++ b/URLShare/ShareViewController.swift @@ -0,0 +1,268 @@ +// +// ShareViewController.swift +// URLShare +// +// Created by Ilyas Hallak on 11.06.25. +// + +import UIKit +import Social +import UniformTypeIdentifiers +import CoreData + +class ShareViewController: SLComposeServiceViewController { + + private var extractedURL: String? + private var extractedTitle: String? + private var isProcessing = false + + override func viewDidLoad() { + super.viewDidLoad() + extractSharedContent() + setupUI() + } + + override func isContentValid() -> Bool { + guard let url = extractedURL, + !url.isEmpty, + !isProcessing, + URL(string: url) != nil else { + return false + } + return true + } + + override func didSelectPost() { + guard let url = extractedURL else { + completeRequest() + return + } + + isProcessing = true + let title = textView.text != extractedTitle ? textView.text : extractedTitle + + // UI Feedback zeigen + let loadingAlert = UIAlertController(title: "Speichere Bookmark", message: "Bitte warten...", preferredStyle: .alert) + present(loadingAlert, animated: true) + + Task { + do { + let response = try await createBookmark(url: url, title: title) + + await MainActor.run { + loadingAlert.dismiss(animated: true) + + if response.status == 0 { + // Erfolg + let successAlert = UIAlertController(title: "Erfolg", message: "Bookmark wurde hinzugefügt!", preferredStyle: .alert) + successAlert.addAction(UIAlertAction(title: "OK", style: .default) { [weak self] _ in + self?.completeRequest() + }) + present(successAlert, animated: true) + } else { + // Fehler vom Server + let errorAlert = UIAlertController(title: "Fehler", message: response.message, preferredStyle: .alert) + errorAlert.addAction(UIAlertAction(title: "OK", style: .default) { [weak self] _ in + self?.completeRequest() + }) + present(errorAlert, animated: true) + } + } + } catch { + await MainActor.run { + loadingAlert.dismiss(animated: true) + let errorAlert = UIAlertController(title: "Fehler", message: error.localizedDescription, preferredStyle: .alert) + errorAlert.addAction(UIAlertAction(title: "OK", style: .default) { [weak self] _ in + self?.completeRequest() + }) + present(errorAlert, animated: true) + } + } + } + } + + // MARK: - Core Data Token Retrieval + + private func getCurrentToken() -> String? { + let context = CoreDataManager.shared.context + let request: NSFetchRequest = SettingEntity.fetchRequest() // Anpassen an deine Entity + + do { + let tokens = try context.fetch(request) + return tokens.first?.token // Anpassen an dein Token-Attribut + } catch { + print("Failed to fetch token from Core Data: \(error)") + return nil + } + } + + // MARK: - API Implementation + + private func createBookmark(url: String, title: String?) async throws -> CreateBookmarkResponseDto { + let requestDto = CreateBookmarkRequestDto(labels: nil, title: title, url: url) + + // Die Server-URL + let baseURL = "https://keep.mnk.any64.de" + let endpoint = "/api/bookmarks" + + guard let requestURL = URL(string: "\(baseURL)\(endpoint)") else { + throw RequestError.invalidURL + } + + var request = URLRequest(url: requestURL) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + // Token aus Core Data holen + guard let token = getCurrentToken() else { + throw RequestError.noToken + } + + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + let encoder = JSONEncoder() + request.httpBody = try encoder.encode(requestDto) + + // Request durchführen + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw RequestError.invalidResponse + } + + guard 200...299 ~= httpResponse.statusCode else { + throw RequestError.serverError(statusCode: httpResponse.statusCode) + } + + // Response dekodieren und zurückgeben + let decoder = JSONDecoder() + return try decoder.decode(CreateBookmarkResponseDto.self, from: data) + } + + // MARK: - Support Methods + + private func setupUI() { + self.title = "Zu readeck hinzufügen" + self.placeholder = "Optional: Titel anpassen..." + + // Zeige URL oder Titel als Kontext + if let title = extractedTitle { + self.textView.text = title + } else if let url = extractedURL { + self.textView.text = URL(string: url)?.host ?? url + } + } + + private func extractSharedContent() { + guard let extensionContext = extensionContext else { return } + + for item in extensionContext.inputItems { + guard let inputItem = item as? NSExtensionItem else { continue } + + for provider in inputItem.attachments ?? [] { + // URL direkt + if provider.hasItemConformingToTypeIdentifier(UTType.url.identifier) { + provider.loadItem(forTypeIdentifier: UTType.url.identifier, options: nil) { [weak self] item, error in + if let url = item as? URL { + DispatchQueue.main.async { + self?.extractedURL = url.absoluteString + self?.extractedTitle = inputItem.attributedTitle?.string ?? inputItem.attributedContentText?.string + self?.setupUI() + } + } + } + } + // Text (könnte URL enthalten) + else if provider.hasItemConformingToTypeIdentifier(UTType.plainText.identifier) { + provider.loadItem(forTypeIdentifier: UTType.plainText.identifier, options: nil) { [weak self] item, error in + if let text = item as? String { + DispatchQueue.main.async { + self?.handleTextContent(text) + } + } + } + } + // Property List (Safari teilt so) + else if provider.hasItemConformingToTypeIdentifier(UTType.propertyList.identifier) { + provider.loadItem(forTypeIdentifier: UTType.propertyList.identifier, options: nil) { [weak self] item, error in + if let dictionary = item as? [String: Any] { + DispatchQueue.main.async { + self?.handlePropertyList(dictionary) + } + } + } + } + } + } + } + + private func handleTextContent(_ text: String) { + let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) + let range = NSRange(text.startIndex..orderHint 1 + URLShare.xcscheme_^#shared#^_ + + orderHint + 1 + readeck.xcscheme_^#shared#^_ orderHint diff --git a/readeck/Info.plist b/readeck/Info.plist new file mode 100644 index 0000000..0f559b5 --- /dev/null +++ b/readeck/Info.plist @@ -0,0 +1,17 @@ + + + + + CFBundleURLTypes + + + CFBundleURLName + readeck.url-handler + CFBundleURLSchemes + + readeck + + + + + diff --git a/readeck/UI/Bookmarks/BookmarkCardView.swift b/readeck/UI/Bookmarks/BookmarkCardView.swift index 7993b60..9d21dff 100644 --- a/readeck/UI/Bookmarks/BookmarkCardView.swift +++ b/readeck/UI/Bookmarks/BookmarkCardView.swift @@ -73,16 +73,26 @@ struct BookmarkCardView: View { .multilineTextAlignment(.leading) } - // Meta-Info - HStack { - if !bookmark.siteName.isEmpty { - Label(bookmark.siteName, systemImage: "globe") + // Meta-Info mit Datum + VStack(alignment: .leading, spacing: 4) { + HStack { + if !bookmark.siteName.isEmpty { + Label(bookmark.siteName, systemImage: "globe") + } + + Spacer() + + if let readingTime = bookmark.readingTime, readingTime > 0 { + Label("\(readingTime) min", systemImage: "clock") + } } - Spacer() - - if let readingTime = bookmark.readingTime, readingTime > 0 { - Label("\(readingTime) min", systemImage: "clock") + // Veröffentlichungsdatum + if let publishedDate = formattedPublishedDate { + HStack { + Label(publishedDate, systemImage: "calendar") + Spacer() + } } } .font(.caption) @@ -106,6 +116,74 @@ struct BookmarkCardView: View { } } + // MARK: - Computed Properties + + private var formattedPublishedDate: String? { + guard let published = bookmark.published, + !published.isEmpty else { + return nil + } + + // Prüfe auf Unix Epoch (1970-01-01) - bedeutet "kein Datum" + if published.contains("1970-01-01") { + return nil + } + + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" + formatter.timeZone = TimeZone(abbreviation: "UTC") + formatter.locale = Locale(identifier: "en_US_POSIX") + + guard let date = formatter.date(from: published) else { + // Fallback ohne Millisekunden + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'" + guard let fallbackDate = formatter.date(from: published) else { + return nil + } + return formatDate(fallbackDate) + } + + return formatDate(date) + } + + private func formatDate(_ date: Date) -> String { + let now = Date() + let calendar = Calendar.current + + // Heute + if calendar.isDateInToday(date) { + let formatter = DateFormatter() + formatter.timeStyle = .short + return "Heute, \(formatter.string(from: date))" + } + + // Gestern + if calendar.isDateInYesterday(date) { + let formatter = DateFormatter() + formatter.timeStyle = .short + return "Gestern, \(formatter.string(from: date))" + } + + // Diese Woche + if calendar.isDate(date, equalTo: now, toGranularity: .weekOfYear) { + let formatter = DateFormatter() + formatter.dateFormat = "EEEE, HH:mm" + return formatter.string(from: date) + } + + // Dieses Jahr + if calendar.isDate(date, equalTo: now, toGranularity: .year) { + let formatter = DateFormatter() + formatter.dateFormat = "d. MMM, HH:mm" + return formatter.string(from: date) + } + + // Andere Jahre + let formatter = DateFormatter() + formatter.dateFormat = "d. MMM yyyy" + return formatter.string(from: date) + } + private var actionButtons: some View { Group { // Favorit Toggle diff --git a/readeck/UI/Bookmarks/BookmarksView.swift b/readeck/UI/Bookmarks/BookmarksView.swift index 58c15fe..d57c276 100644 --- a/readeck/UI/Bookmarks/BookmarksView.swift +++ b/readeck/UI/Bookmarks/BookmarksView.swift @@ -3,6 +3,8 @@ import SwiftUI struct BookmarksView: View { @State private var viewModel = BookmarksViewModel() @State private var showingAddBookmark = false + @State private var scrollOffset: CGFloat = 0 + @State private var isScrolling = false let state: BookmarkState var body: some View { @@ -55,16 +57,7 @@ struct BookmarksView: View { } } } - .navigationTitle(state.displayName) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button(action: { - showingAddBookmark = true - }) { - Image(systemName: "plus") - } - } - } + .navigationTitle(state.displayName) .sheet(isPresented: $showingAddBookmark) { AddBookmarkView() } @@ -87,5 +80,39 @@ struct BookmarksView: View { } } } + .overlay { + // Animated FAB Button + VStack { + Spacer() + HStack { + Spacer() + + Button(action: { + showingAddBookmark = true + }) { + Image(systemName: "plus") + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.white) + .frame(width: 56, height: 56) + .background(Color.accentColor) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.25), radius: 6, x: 0, y: 3) + .scaleEffect(isScrolling ? 0.8 : 1.0) + .opacity(isScrolling ? 0.7 : 1.0) + } + .padding(.trailing, 20) + .padding(.bottom, 20) + } + } + } + } +} + +// Helper für Scroll-Tracking +struct ScrollOffsetPreferenceKey: PreferenceKey { + static var defaultValue: CGFloat = 0 + static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) { + value = nextValue() } } diff --git a/readeck/Domain/DefaultUseCaseFactory.swift b/readeck/UI/DefaultUseCaseFactory.swift similarity index 100% rename from readeck/Domain/DefaultUseCaseFactory.swift rename to readeck/UI/DefaultUseCaseFactory.swift diff --git a/readeck/UI/readeckApp.swift b/readeck/UI/readeckApp.swift index 2aee4a6..6c2dd3e 100644 --- a/readeck/UI/readeckApp.swift +++ b/readeck/UI/readeckApp.swift @@ -15,6 +15,35 @@ struct readeckApp: App { WindowGroup { MainTabView() .environment(\.managedObjectContext, persistenceController.container.viewContext) + .onOpenURL { url in + handleIncomingURL(url) + } } } + + private func handleIncomingURL(_ url: URL) { + guard url.scheme == "readeck", + url.host == "add-bookmark" else { + return + } + + let components = URLComponents(url: url, resolvingAgainstBaseURL: true) + let queryItems = components?.queryItems + + let urlToAdd = queryItems?.first(where: { $0.name == "url" })?.value + let title = queryItems?.first(where: { $0.name == "title" })?.value + let notes = queryItems?.first(where: { $0.name == "notes" })?.value + + // Öffne AddBookmarkView mit den Daten + // Hier kannst du eine Notification posten oder einen State ändern + NotificationCenter.default.post( + name: NSNotification.Name("AddBookmarkFromShare"), + object: nil, + userInfo: [ + "url": urlToAdd ?? "", + "title": title ?? "", + "notes": notes ?? "" + ] + ) + } }