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:
parent
da7bb1613c
commit
82f9d8a5a9
24
URLShare/Base.lproj/MainInterface.storyboard
Normal file
24
URLShare/Base.lproj/MainInterface.storyboard
Normal 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
21
URLShare/Info.plist
Normal 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>
|
||||
268
URLShare/ShareViewController.swift
Normal file
268
URLShare/ShareViewController.swift
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -6,7 +6,18 @@
|
||||
objectVersion = 77;
|
||||
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 */
|
||||
5D2B7FB72DFA27A400EBDB2B /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 5D45F9C02DF858680048D5B8 /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 5D2B7FAE2DFA27A400EBDB2B;
|
||||
remoteInfo = URLShare;
|
||||
};
|
||||
5D45F9DF2DF8586A0048D5B8 /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 5D45F9C02DF858680048D5B8 /* Project object */;
|
||||
@ -24,6 +35,17 @@
|
||||
/* End PBXContainerItemProxy 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 */ = {
|
||||
isa = PBXCopyFilesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@ -37,14 +59,53 @@
|
||||
/* End PBXCopyFilesBuildPhase 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; };
|
||||
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; };
|
||||
/* 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 */
|
||||
5D2B7FB02DFA27A400EBDB2B /* URLShare */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
5D2B7FBA2DFA27A400EBDB2B /* Exceptions for "URLShare" folder in "URLShare" target */,
|
||||
);
|
||||
path = URLShare;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5D45F9CA2DF858680048D5B8 /* readeck */ = {
|
||||
isa = PBXFileSystemSynchronizedRootGroup;
|
||||
exceptions = (
|
||||
5DCD48BE2DFB47A800AC7FB6 /* Exceptions for "readeck" folder in "readeck" target */,
|
||||
5DCD48B72DFB44D600AC7FB6 /* Exceptions for "readeck" folder in "URLShare" target */,
|
||||
);
|
||||
path = readeck;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@ -61,6 +122,13 @@
|
||||
/* End PBXFileSystemSynchronizedRootGroup section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
5D2B7FAC2DFA27A400EBDB2B /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
5D45F9C52DF858680048D5B8 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@ -91,6 +159,7 @@
|
||||
5D45F9CA2DF858680048D5B8 /* readeck */,
|
||||
5D45F9E12DF8586A0048D5B8 /* readeckTests */,
|
||||
5D45F9EB2DF8586A0048D5B8 /* readeckUITests */,
|
||||
5D2B7FB02DFA27A400EBDB2B /* URLShare */,
|
||||
5D45F9C92DF858680048D5B8 /* Products */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
@ -101,6 +170,7 @@
|
||||
5D45F9C82DF858680048D5B8 /* readeck.app */,
|
||||
5D45F9DE2DF8586A0048D5B8 /* readeckTests.xctest */,
|
||||
5D45F9E82DF8586A0048D5B8 /* readeckUITests.xctest */,
|
||||
5D2B7FAF2DFA27A400EBDB2B /* URLShare.appex */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@ -108,6 +178,28 @@
|
||||
/* End PBXGroup 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 */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 5D45F9F22DF8586A0048D5B8 /* Build configuration list for PBXNativeTarget "readeck" */;
|
||||
@ -116,10 +208,12 @@
|
||||
5D45F9C52DF858680048D5B8 /* Frameworks */,
|
||||
5D45F9C62DF858680048D5B8 /* Resources */,
|
||||
5D45FA1D2DF865BE0048D5B8 /* Embed Frameworks */,
|
||||
5D2B7FBE2DFA27A400EBDB2B /* Embed Foundation Extensions */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
5D2B7FB82DFA27A400EBDB2B /* PBXTargetDependency */,
|
||||
);
|
||||
fileSystemSynchronizedGroups = (
|
||||
5D45F9CA2DF858680048D5B8 /* readeck */,
|
||||
@ -187,6 +281,9 @@
|
||||
LastSwiftUpdateCheck = 1610;
|
||||
LastUpgradeCheck = 1610;
|
||||
TargetAttributes = {
|
||||
5D2B7FAE2DFA27A400EBDB2B = {
|
||||
CreatedOnToolsVersion = 16.1;
|
||||
};
|
||||
5D45F9C72DF858680048D5B8 = {
|
||||
CreatedOnToolsVersion = 16.1;
|
||||
};
|
||||
@ -217,11 +314,19 @@
|
||||
5D45F9C72DF858680048D5B8 /* readeck */,
|
||||
5D45F9DD2DF8586A0048D5B8 /* readeckTests */,
|
||||
5D45F9E72DF8586A0048D5B8 /* readeckUITests */,
|
||||
5D2B7FAE2DFA27A400EBDB2B /* URLShare */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
5D2B7FAD2DFA27A400EBDB2B /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
5D45F9C62DF858680048D5B8 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@ -246,6 +351,13 @@
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
5D2B7FAB2DFA27A400EBDB2B /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
5D45F9C42DF858680048D5B8 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@ -270,6 +382,11 @@
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
5D2B7FB82DFA27A400EBDB2B /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 5D2B7FAE2DFA27A400EBDB2B /* URLShare */;
|
||||
targetProxy = 5D2B7FB72DFA27A400EBDB2B /* PBXContainerItemProxy */;
|
||||
};
|
||||
5D45F9E02DF8586A0048D5B8 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 5D45F9C72DF858680048D5B8 /* readeck */;
|
||||
@ -283,6 +400,61 @@
|
||||
/* End PBXTargetDependency 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 */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@ -410,6 +582,8 @@
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = 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=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||
@ -449,6 +623,8 @@
|
||||
ENABLE_HARDENED_RUNTIME = YES;
|
||||
ENABLE_PREVIEWS = 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=iphonesimulator*]" = YES;
|
||||
"INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES;
|
||||
@ -568,6 +744,15 @@
|
||||
/* End XCBuildConfiguration 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" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
|
||||
@ -9,6 +9,11 @@
|
||||
<key>orderHint</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
<key>URLShare.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
<key>readeck.xcscheme_^#shared#^_</key>
|
||||
<dict>
|
||||
<key>orderHint</key>
|
||||
|
||||
17
readeck/Info.plist
Normal file
17
readeck/Info.plist
Normal 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>
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
@ -56,15 +58,6 @@ struct BookmarksView: View {
|
||||
}
|
||||
}
|
||||
.navigationTitle(state.displayName)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: {
|
||||
showingAddBookmark = true
|
||||
}) {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
}
|
||||
}
|
||||
.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()
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 ?? ""
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user