feat: Add URL Share Extension with API integration

- Add ShareViewController with complete URL extraction logic
- Support URL sharing from Safari, Chrome and other apps
- Extract URLs from different content types (URL, plain text, property list)
- Implement direct API call to create bookmarks from share extension
- Add Core Data integration to fetch authentication token
- Include proper error handling and user feedback with alerts
- Support title extraction and user text input for bookmark creation

Technical implementation:
- Handle UTType.url, UTType.plainText, and UTType.propertyList
- Async/await pattern for API requests
- NSDataDetector for URL extraction from text
- Property list parsing for Safari-style sharing
- Loading and success/error alerts for better UX
This commit is contained in:
Ilyas Hallak 2025-06-12 23:21:55 +02:00
parent da7bb1613c
commit 82f9d8a5a9
10 changed files with 672 additions and 18 deletions

View File

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="13122.16" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" useSafeAreas="YES" colorMatched="YES" initialViewController="j1y-V4-xli">
<dependencies>
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="13104.12"/>
<capability name="Safe area layout guides" minToolsVersion="9.0"/>
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
</dependencies>
<scenes>
<!--Share View Controller-->
<scene sceneID="ceB-am-kn3">
<objects>
<viewController id="j1y-V4-xli" customClass="ShareViewController" customModuleProvider="target" sceneMemberID="viewController">
<view key="view" opaque="NO" contentMode="scaleToFill" id="wbc-yd-nQP">
<rect key="frame" x="0.0" y="0.0" width="375" height="667"/>
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
<color key="backgroundColor" red="0.0" green="0.0" blue="0.0" alpha="0.0" colorSpace="custom" customColorSpace="sRGB"/>
<viewLayoutGuide key="safeArea" id="1Xd-am-t49"/>
</view>
</viewController>
<placeholder placeholderIdentifier="IBFirstResponder" id="CEy-Cv-SGf" userLabel="First Responder" sceneMemberID="firstResponder"/>
</objects>
</scene>
</scenes>
</document>

21
URLShare/Info.plist Normal file
View File

@ -0,0 +1,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
</dict>
</dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.share-services</string>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
</dict>
</plist>

View File

@ -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> = 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..<text.endIndex, in: text)
if let match = detector?.firstMatch(in: text, options: [], range: range),
let url = match.url {
extractedURL = url.absoluteString
extractedTitle = text != url.absoluteString ? text : nil
} else if URL(string: text) != nil {
extractedURL = text
}
setupUI()
}
private func handlePropertyList(_ dictionary: [String: Any]) {
if let urlString = dictionary[NSExtensionJavaScriptPreprocessingResultsKey] as? [String: Any],
let url = urlString["URL"] as? String {
extractedURL = url
extractedTitle = urlString["title"] as? String
} else if let url = dictionary["URL"] as? String {
extractedURL = url
extractedTitle = dictionary["title"] as? String
}
setupUI()
}
private func completeRequest() {
isProcessing = false
extensionContext?.completeRequest(returningItems: [], completionHandler: nil)
}
}
// MARK: - DTOs und Error Handling
struct CreateBookmarkRequestDto: Codable {
let labels: [String]?
let title: String?
let url: String
}
struct CreateBookmarkResponseDto: Codable {
let message: String
let status: Int
}
enum RequestError: Error, LocalizedError {
case invalidURL
case invalidResponse
case serverError(statusCode: Int)
case decodingError
case noToken
var errorDescription: String? {
switch self {
case .invalidURL:
return "Ungültige URL"
case .invalidResponse:
return "Ungültige Server-Antwort"
case .serverError(let code):
return "Server-Fehler: \(code)"
case .decodingError:
return "Fehler beim Dekodieren der Antwort"
case .noToken:
return "Kein Authentifizierungs-Token gefunden"
}
}
}

View File

@ -6,7 +6,18 @@
objectVersion = 77; objectVersion = 77;
objects = { objects = {
/* Begin PBXBuildFile section */
5D2B7FB92DFA27A400EBDB2B /* URLShare.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 5D2B7FAF2DFA27A400EBDB2B /* URLShare.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */ /* Begin PBXContainerItemProxy section */
5D2B7FB72DFA27A400EBDB2B /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 5D45F9C02DF858680048D5B8 /* Project object */;
proxyType = 1;
remoteGlobalIDString = 5D2B7FAE2DFA27A400EBDB2B;
remoteInfo = URLShare;
};
5D45F9DF2DF8586A0048D5B8 /* PBXContainerItemProxy */ = { 5D45F9DF2DF8586A0048D5B8 /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy; isa = PBXContainerItemProxy;
containerPortal = 5D45F9C02DF858680048D5B8 /* Project object */; containerPortal = 5D45F9C02DF858680048D5B8 /* Project object */;
@ -24,6 +35,17 @@
/* End PBXContainerItemProxy section */ /* End PBXContainerItemProxy section */
/* Begin PBXCopyFilesBuildPhase section */ /* Begin PBXCopyFilesBuildPhase section */
5D2B7FBE2DFA27A400EBDB2B /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647;
dstPath = "";
dstSubfolderSpec = 13;
files = (
5D2B7FB92DFA27A400EBDB2B /* URLShare.appex in Embed Foundation Extensions */,
);
name = "Embed Foundation Extensions";
runOnlyForDeploymentPostprocessing = 0;
};
5D45FA1D2DF865BE0048D5B8 /* Embed Frameworks */ = { 5D45FA1D2DF865BE0048D5B8 /* Embed Frameworks */ = {
isa = PBXCopyFilesBuildPhase; isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@ -37,14 +59,53 @@
/* End PBXCopyFilesBuildPhase section */ /* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */ /* Begin PBXFileReference section */
5D2B7FAF2DFA27A400EBDB2B /* URLShare.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = URLShare.appex; sourceTree = BUILT_PRODUCTS_DIR; };
5D45F9C82DF858680048D5B8 /* readeck.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = readeck.app; sourceTree = BUILT_PRODUCTS_DIR; }; 5D45F9C82DF858680048D5B8 /* readeck.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = readeck.app; sourceTree = BUILT_PRODUCTS_DIR; };
5D45F9DE2DF8586A0048D5B8 /* readeckTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = readeckTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5D45F9DE2DF8586A0048D5B8 /* readeckTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = readeckTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
5D45F9E82DF8586A0048D5B8 /* readeckUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = readeckUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 5D45F9E82DF8586A0048D5B8 /* readeckUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = readeckUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */ /* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
5D2B7FBA2DFA27A400EBDB2B /* Exceptions for "URLShare" folder in "URLShare" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 5D2B7FAE2DFA27A400EBDB2B /* URLShare */;
};
5DCD48B72DFB44D600AC7FB6 /* Exceptions for "readeck" folder in "URLShare" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Data/CoreData/CoreDataManager.swift,
Domain/Model/Bookmark.swift,
readeck.xcdatamodeld,
);
target = 5D2B7FAE2DFA27A400EBDB2B /* URLShare */;
};
5DCD48BE2DFB47A800AC7FB6 /* Exceptions for "readeck" folder in "readeck" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = (
Info.plist,
);
target = 5D45F9C72DF858680048D5B8 /* readeck */;
};
/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */
/* Begin PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFileSystemSynchronizedRootGroup section */
5D2B7FB02DFA27A400EBDB2B /* URLShare */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
5D2B7FBA2DFA27A400EBDB2B /* Exceptions for "URLShare" folder in "URLShare" target */,
);
path = URLShare;
sourceTree = "<group>";
};
5D45F9CA2DF858680048D5B8 /* readeck */ = { 5D45F9CA2DF858680048D5B8 /* readeck */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
5DCD48BE2DFB47A800AC7FB6 /* Exceptions for "readeck" folder in "readeck" target */,
5DCD48B72DFB44D600AC7FB6 /* Exceptions for "readeck" folder in "URLShare" target */,
);
path = readeck; path = readeck;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
@ -61,6 +122,13 @@
/* End PBXFileSystemSynchronizedRootGroup section */ /* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */ /* Begin PBXFrameworksBuildPhase section */
5D2B7FAC2DFA27A400EBDB2B /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
5D45F9C52DF858680048D5B8 /* Frameworks */ = { 5D45F9C52DF858680048D5B8 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase; isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@ -91,6 +159,7 @@
5D45F9CA2DF858680048D5B8 /* readeck */, 5D45F9CA2DF858680048D5B8 /* readeck */,
5D45F9E12DF8586A0048D5B8 /* readeckTests */, 5D45F9E12DF8586A0048D5B8 /* readeckTests */,
5D45F9EB2DF8586A0048D5B8 /* readeckUITests */, 5D45F9EB2DF8586A0048D5B8 /* readeckUITests */,
5D2B7FB02DFA27A400EBDB2B /* URLShare */,
5D45F9C92DF858680048D5B8 /* Products */, 5D45F9C92DF858680048D5B8 /* Products */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
@ -101,6 +170,7 @@
5D45F9C82DF858680048D5B8 /* readeck.app */, 5D45F9C82DF858680048D5B8 /* readeck.app */,
5D45F9DE2DF8586A0048D5B8 /* readeckTests.xctest */, 5D45F9DE2DF8586A0048D5B8 /* readeckTests.xctest */,
5D45F9E82DF8586A0048D5B8 /* readeckUITests.xctest */, 5D45F9E82DF8586A0048D5B8 /* readeckUITests.xctest */,
5D2B7FAF2DFA27A400EBDB2B /* URLShare.appex */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
@ -108,6 +178,28 @@
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget section */ /* Begin PBXNativeTarget section */
5D2B7FAE2DFA27A400EBDB2B /* URLShare */ = {
isa = PBXNativeTarget;
buildConfigurationList = 5D2B7FBB2DFA27A400EBDB2B /* Build configuration list for PBXNativeTarget "URLShare" */;
buildPhases = (
5D2B7FAB2DFA27A400EBDB2B /* Sources */,
5D2B7FAC2DFA27A400EBDB2B /* Frameworks */,
5D2B7FAD2DFA27A400EBDB2B /* Resources */,
);
buildRules = (
);
dependencies = (
);
fileSystemSynchronizedGroups = (
5D2B7FB02DFA27A400EBDB2B /* URLShare */,
);
name = URLShare;
packageProductDependencies = (
);
productName = URLShare;
productReference = 5D2B7FAF2DFA27A400EBDB2B /* URLShare.appex */;
productType = "com.apple.product-type.app-extension";
};
5D45F9C72DF858680048D5B8 /* readeck */ = { 5D45F9C72DF858680048D5B8 /* readeck */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 5D45F9F22DF8586A0048D5B8 /* Build configuration list for PBXNativeTarget "readeck" */; buildConfigurationList = 5D45F9F22DF8586A0048D5B8 /* Build configuration list for PBXNativeTarget "readeck" */;
@ -116,10 +208,12 @@
5D45F9C52DF858680048D5B8 /* Frameworks */, 5D45F9C52DF858680048D5B8 /* Frameworks */,
5D45F9C62DF858680048D5B8 /* Resources */, 5D45F9C62DF858680048D5B8 /* Resources */,
5D45FA1D2DF865BE0048D5B8 /* Embed Frameworks */, 5D45FA1D2DF865BE0048D5B8 /* Embed Frameworks */,
5D2B7FBE2DFA27A400EBDB2B /* Embed Foundation Extensions */,
); );
buildRules = ( buildRules = (
); );
dependencies = ( dependencies = (
5D2B7FB82DFA27A400EBDB2B /* PBXTargetDependency */,
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
5D45F9CA2DF858680048D5B8 /* readeck */, 5D45F9CA2DF858680048D5B8 /* readeck */,
@ -187,6 +281,9 @@
LastSwiftUpdateCheck = 1610; LastSwiftUpdateCheck = 1610;
LastUpgradeCheck = 1610; LastUpgradeCheck = 1610;
TargetAttributes = { TargetAttributes = {
5D2B7FAE2DFA27A400EBDB2B = {
CreatedOnToolsVersion = 16.1;
};
5D45F9C72DF858680048D5B8 = { 5D45F9C72DF858680048D5B8 = {
CreatedOnToolsVersion = 16.1; CreatedOnToolsVersion = 16.1;
}; };
@ -217,11 +314,19 @@
5D45F9C72DF858680048D5B8 /* readeck */, 5D45F9C72DF858680048D5B8 /* readeck */,
5D45F9DD2DF8586A0048D5B8 /* readeckTests */, 5D45F9DD2DF8586A0048D5B8 /* readeckTests */,
5D45F9E72DF8586A0048D5B8 /* readeckUITests */, 5D45F9E72DF8586A0048D5B8 /* readeckUITests */,
5D2B7FAE2DFA27A400EBDB2B /* URLShare */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */ /* Begin PBXResourcesBuildPhase section */
5D2B7FAD2DFA27A400EBDB2B /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
5D45F9C62DF858680048D5B8 /* Resources */ = { 5D45F9C62DF858680048D5B8 /* Resources */ = {
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@ -246,6 +351,13 @@
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
5D2B7FAB2DFA27A400EBDB2B /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
5D45F9C42DF858680048D5B8 /* Sources */ = { 5D45F9C42DF858680048D5B8 /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@ -270,6 +382,11 @@
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */ /* Begin PBXTargetDependency section */
5D2B7FB82DFA27A400EBDB2B /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 5D2B7FAE2DFA27A400EBDB2B /* URLShare */;
targetProxy = 5D2B7FB72DFA27A400EBDB2B /* PBXContainerItemProxy */;
};
5D45F9E02DF8586A0048D5B8 /* PBXTargetDependency */ = { 5D45F9E02DF8586A0048D5B8 /* PBXTargetDependency */ = {
isa = PBXTargetDependency; isa = PBXTargetDependency;
target = 5D45F9C72DF858680048D5B8 /* readeck */; target = 5D45F9C72DF858680048D5B8 /* readeck */;
@ -283,6 +400,61 @@
/* End PBXTargetDependency section */ /* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
5D2B7FBC2DFA27A400EBDB2B /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 8J69P655GN;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = URLShare/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = URLShare;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 18.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck.URLShare;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
};
name = Debug;
};
5D2B7FBD2DFA27A400EBDB2B /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 8J69P655GN;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = URLShare/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = URLShare;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
IPHONEOS_DEPLOYMENT_TARGET = 18.1;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@executable_path/../../Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck.URLShare;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = iphoneos;
SKIP_INSTALL = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VALIDATE_PRODUCT = YES;
};
name = Release;
};
5D45F9F02DF8586A0048D5B8 /* Debug */ = { 5D45F9F02DF8586A0048D5B8 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
@ -410,6 +582,8 @@
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = readeck/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@ -449,6 +623,8 @@
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES; ENABLE_PREVIEWS = YES;
GENERATE_INFOPLIST_FILE = YES; GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = readeck/Info.plist;
INFOPLIST_KEY_LSApplicationCategoryType = "";
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES;
"INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES;
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
@ -568,6 +744,15 @@
/* End XCBuildConfiguration section */ /* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */ /* Begin XCConfigurationList section */
5D2B7FBB2DFA27A400EBDB2B /* Build configuration list for PBXNativeTarget "URLShare" */ = {
isa = XCConfigurationList;
buildConfigurations = (
5D2B7FBC2DFA27A400EBDB2B /* Debug */,
5D2B7FBD2DFA27A400EBDB2B /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
5D45F9C32DF858680048D5B8 /* Build configuration list for PBXProject "readeck" */ = { 5D45F9C32DF858680048D5B8 /* Build configuration list for PBXProject "readeck" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (

View File

@ -9,6 +9,11 @@
<key>orderHint</key> <key>orderHint</key>
<integer>1</integer> <integer>1</integer>
</dict> </dict>
<key>URLShare.xcscheme_^#shared#^_</key>
<dict>
<key>orderHint</key>
<integer>1</integer>
</dict>
<key>readeck.xcscheme_^#shared#^_</key> <key>readeck.xcscheme_^#shared#^_</key>
<dict> <dict>
<key>orderHint</key> <key>orderHint</key>

17
readeck/Info.plist Normal file
View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string>readeck.url-handler</string>
<key>CFBundleURLSchemes</key>
<array>
<string>readeck</string>
</array>
</dict>
</array>
</dict>
</plist>

View File

@ -73,7 +73,8 @@ struct BookmarkCardView: View {
.multilineTextAlignment(.leading) .multilineTextAlignment(.leading)
} }
// Meta-Info // Meta-Info mit Datum
VStack(alignment: .leading, spacing: 4) {
HStack { HStack {
if !bookmark.siteName.isEmpty { if !bookmark.siteName.isEmpty {
Label(bookmark.siteName, systemImage: "globe") Label(bookmark.siteName, systemImage: "globe")
@ -85,6 +86,15 @@ struct BookmarkCardView: View {
Label("\(readingTime) min", systemImage: "clock") Label("\(readingTime) min", systemImage: "clock")
} }
} }
// Veröffentlichungsdatum
if let publishedDate = formattedPublishedDate {
HStack {
Label(publishedDate, systemImage: "calendar")
Spacer()
}
}
}
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .foregroundColor(.secondary)
@ -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 { private var actionButtons: some View {
Group { Group {
// Favorit Toggle // Favorit Toggle

View File

@ -3,6 +3,8 @@ import SwiftUI
struct BookmarksView: View { struct BookmarksView: View {
@State private var viewModel = BookmarksViewModel() @State private var viewModel = BookmarksViewModel()
@State private var showingAddBookmark = false @State private var showingAddBookmark = false
@State private var scrollOffset: CGFloat = 0
@State private var isScrolling = false
let state: BookmarkState let state: BookmarkState
var body: some View { var body: some View {
@ -56,15 +58,6 @@ struct BookmarksView: View {
} }
} }
.navigationTitle(state.displayName) .navigationTitle(state.displayName)
.toolbar {
ToolbarItem(placement: .navigationBarTrailing) {
Button(action: {
showingAddBookmark = true
}) {
Image(systemName: "plus")
}
}
}
.sheet(isPresented: $showingAddBookmark) { .sheet(isPresented: $showingAddBookmark) {
AddBookmarkView() 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()
} }
} }

View File

@ -15,6 +15,35 @@ struct readeckApp: App {
WindowGroup { WindowGroup {
MainTabView() MainTabView()
.environment(\.managedObjectContext, persistenceController.container.viewContext) .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 ?? ""
]
)
}
}