diff --git a/URLShare/Info.plist b/URLShare/Info.plist index 36d4d3e..ea3b19a 100644 --- a/URLShare/Info.plist +++ b/URLShare/Info.plist @@ -5,17 +5,17 @@ NSExtension NSExtensionAttributes - - NSExtensionActivationRule - - NSExtensionActivationSupportsWebURLWithMaxCount - 1 - - + + NSExtensionActivationRule + + NSExtensionActivationSupportsWebURLWithMaxCount + 1 + + NSExtensionPointIdentifier com.apple.share-services - NSExtensionPrincipalClass - $(PRODUCT_MODULE_NAME).ShareViewController + NSExtensionPrincipalClass + $(PRODUCT_MODULE_NAME).ShareViewController diff --git a/readeck.xcodeproj/project.pbxproj b/readeck.xcodeproj/project.pbxproj index 71e7733..0878df2 100644 --- a/readeck.xcodeproj/project.pbxproj +++ b/readeck.xcodeproj/project.pbxproj @@ -415,6 +415,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = URLShare/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = URLShare; + INFOPLIST_KEY_LSApplicationCategoryType = ""; INFOPLIST_KEY_NSHumanReadableCopyright = ""; IPHONEOS_DEPLOYMENT_TARGET = 18.1; LD_RUNPATH_SEARCH_PATHS = ( @@ -423,7 +424,7 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck.URLShare; + PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck2.URLShare; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; @@ -442,6 +443,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = URLShare/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = URLShare; + INFOPLIST_KEY_LSApplicationCategoryType = ""; INFOPLIST_KEY_NSHumanReadableCopyright = ""; IPHONEOS_DEPLOYMENT_TARGET = 18.1; LD_RUNPATH_SEARCH_PATHS = ( @@ -450,7 +452,7 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck.URLShare; + PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck2.URLShare; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; @@ -581,6 +583,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\""; @@ -605,8 +608,9 @@ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 15.1; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck; + PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck2; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; SWIFT_EMIT_LOC_STRINGS = YES; @@ -622,6 +626,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\""; @@ -646,8 +651,9 @@ "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; MACOSX_DEPLOYMENT_TARGET = 15.1; MARKETING_VERSION = 1.0; - PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck; + PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck2; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator"; SWIFT_EMIT_LOC_STRINGS = YES; diff --git a/readeck/Assets.xcassets/green.colorset/Contents.json b/readeck/Assets.xcassets/green.colorset/Contents.json new file mode 100644 index 0000000..f5a0250 --- /dev/null +++ b/readeck/Assets.xcassets/green.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x5B", + "green" : "0x4D", + "red" : "0x00" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x5B", + "green" : "0x4D", + "red" : "0x00" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/readeck/Assets.xcassets/placeholder.imageset/Bildschirmfoto 2025-07-03 um 15.09.44.png b/readeck/Assets.xcassets/placeholder.imageset/Bildschirmfoto 2025-07-03 um 15.09.44.png new file mode 100644 index 0000000..5666b2e Binary files /dev/null and b/readeck/Assets.xcassets/placeholder.imageset/Bildschirmfoto 2025-07-03 um 15.09.44.png differ diff --git a/readeck/Assets.xcassets/placeholder.imageset/Contents.json b/readeck/Assets.xcassets/placeholder.imageset/Contents.json new file mode 100644 index 0000000..ed3dfcc --- /dev/null +++ b/readeck/Assets.xcassets/placeholder.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "Bildschirmfoto 2025-07-03 um 15.09.44.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/readeck/Assets.xcassets/readeck.imageset/Contents.json b/readeck/Assets.xcassets/readeck.imageset/Contents.json new file mode 100644 index 0000000..59c7294 --- /dev/null +++ b/readeck/Assets.xcassets/readeck.imageset/Contents.json @@ -0,0 +1,23 @@ +{ + "images" : [ + { + "filename" : "readeck2@1x.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "readeck2@2x.png", + "idiom" : "universal", + "scale" : "2x" + }, + { + "filename" : "readeck2@3x.png", + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/readeck/Assets.xcassets/readeck.imageset/readeck2@1x.png b/readeck/Assets.xcassets/readeck.imageset/readeck2@1x.png new file mode 100644 index 0000000..46d8037 Binary files /dev/null and b/readeck/Assets.xcassets/readeck.imageset/readeck2@1x.png differ diff --git a/readeck/Assets.xcassets/readeck.imageset/readeck2@2x.png b/readeck/Assets.xcassets/readeck.imageset/readeck2@2x.png new file mode 100644 index 0000000..2dd65d6 Binary files /dev/null and b/readeck/Assets.xcassets/readeck.imageset/readeck2@2x.png differ diff --git a/readeck/Assets.xcassets/readeck.imageset/readeck2@3x.png b/readeck/Assets.xcassets/readeck.imageset/readeck2@3x.png new file mode 100644 index 0000000..32d17ea Binary files /dev/null and b/readeck/Assets.xcassets/readeck.imageset/readeck2@3x.png differ diff --git a/readeck/Data/API/API.swift b/readeck/Data/API/API.swift index ba720c3..ba959fc 100644 --- a/readeck/Data/API/API.swift +++ b/readeck/Data/API/API.swift @@ -9,13 +9,14 @@ import Foundation protocol PAPI { var tokenProvider: TokenProvider { get } - func login(username: String, password: String) async throws -> UserDto + func login(endpoint: String, username: String, password: String) async throws -> UserDto func getBookmarks(state: BookmarkState?, limit: Int?, offset: Int?, search: String?, type: [BookmarkType]?) async throws -> BookmarksPageDto func getBookmark(id: String) async throws -> BookmarkDetailDto func getBookmarkArticle(id: String) async throws -> String func createBookmark(createRequest: CreateBookmarkRequestDto) async throws -> CreateBookmarkResponseDto func updateBookmark(id: String, updateRequest: UpdateBookmarkRequestDto) async throws func deleteBookmark(id: String) async throws + func searchBookmarks(search: String) async throws -> BookmarksPageDto } class API: PAPI { @@ -28,10 +29,12 @@ class API: PAPI { private var baseURL: String { get async { - if let cached = cachedBaseURL { + if let cached = cachedBaseURL, cached.isEmpty == false { return cached } - let url = await tokenProvider.getEndpoint() + guard let url = await tokenProvider.getEndpoint() else { + return "" + } cachedBaseURL = url return url } @@ -154,20 +157,25 @@ class API: PAPI { return string } - func login(username: String, password: String) async throws -> UserDto { + func login(endpoint: String, username: String, password: String) async throws -> UserDto { let loginRequest = LoginRequestDto(application: "api doc", username: username, password: password) let requestData = try JSONEncoder().encode(loginRequest) - - let userDto = try await makeJSONRequest( - endpoint: "/api/auth", - method: .POST, - body: requestData, - responseType: UserDto.self - ) - - // Token automatisch speichern nach erfolgreichem Login - await tokenProvider.setToken(userDto.token) - + guard let url = URL(string: endpoint + "/api/auth") else { + throw APIError.invalidURL + } + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = requestData + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse else { + throw APIError.invalidResponse + } + guard 200...299 ~= httpResponse.statusCode else { + throw APIError.serverError(httpResponse.statusCode) + } + let userDto = try JSONDecoder().decode(UserDto.self, from: data) + // Token NICHT automatisch speichern, da Settings noch nicht existieren return userDto } @@ -322,6 +330,26 @@ class API: PAPI { throw APIError.serverError(httpResponse.statusCode) } } + + func searchBookmarks(search: String) async throws -> BookmarksPageDto { + let endpoint = "/api/bookmarks?search=\(search.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")" + let (bookmarks, response) = try await makeJSONRequestWithHeaders( + endpoint: endpoint, + responseType: [BookmarkDto].self + ) + let currentPage = response.value(forHTTPHeaderField: "Current-Page").flatMap { Int($0) } + let totalCount = response.value(forHTTPHeaderField: "Total-Count").flatMap { Int($0) } + let totalPages = response.value(forHTTPHeaderField: "Total-Pages").flatMap { Int($0) } + let linksHeader = response.value(forHTTPHeaderField: "Link") + let links = linksHeader?.components(separatedBy: ",") + return BookmarksPageDto( + bookmarks: bookmarks, + currentPage: currentPage, + totalCount: totalCount, + totalPages: totalPages, + links: links + ) + } } enum HTTPMethod: String { diff --git a/readeck/Data/Repository/AuthRepository.swift b/readeck/Data/Repository/AuthRepository.swift index 09a2097..0dd24b9 100644 --- a/readeck/Data/Repository/AuthRepository.swift +++ b/readeck/Data/Repository/AuthRepository.swift @@ -9,8 +9,8 @@ class AuthRepository: PAuthRepository { self.settingsRepository = settingsRepository } - func login(username: String, password: String) async throws -> User { - let userDto = try await api.login(username: username, password: password) + func login(endpoint: String, username: String, password: String) async throws -> User { + let userDto = try await api.login(endpoint: endpoint, username: username, password: password) // Token wird automatisch von der API gespeichert return User(id: userDto.id, token: userDto.token) } diff --git a/readeck/Data/Repository/BookmarksRepository.swift b/readeck/Data/Repository/BookmarksRepository.swift index 8b448e5..1e5768c 100644 --- a/readeck/Data/Repository/BookmarksRepository.swift +++ b/readeck/Data/Repository/BookmarksRepository.swift @@ -7,6 +7,7 @@ protocol PBookmarksRepository { func createBookmark(createRequest: CreateBookmarkRequest) async throws -> String func updateBookmark(id: String, updateRequest: BookmarkUpdateRequest) async throws func deleteBookmark(id: String) async throws + func searchBookmarks(search: String) async throws -> BookmarksPage } class BookmarksRepository: PBookmarksRepository { @@ -82,6 +83,11 @@ class BookmarksRepository: PBookmarksRepository { try await api.updateBookmark(id: id, updateRequest: dto) } + + func searchBookmarks(search: String) async throws -> BookmarksPage { + let bookmarkDtos = try await api.searchBookmarks(search: search) + return bookmarkDtos.toDomain() + } } struct BookmarkDetail { diff --git a/readeck/Data/Repository/SettingsRepository.swift b/readeck/Data/Repository/SettingsRepository.swift index 79685fe..92c60ce 100644 --- a/readeck/Data/Repository/SettingsRepository.swift +++ b/readeck/Data/Repository/SettingsRepository.swift @@ -28,6 +28,7 @@ protocol PSettingsRepository { func saveUsername(_ username: String) async throws func savePassword(_ password: String) async throws func saveHasFinishedSetup(_ hasFinishedSetup: Bool) async throws + func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws var hasFinishedSetup: Bool { get } } @@ -170,6 +171,40 @@ class SettingsRepository: PSettingsRepository { } } + func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws { + let context = coreDataManager.context + return try await withCheckedThrowingContinuation { continuation in + context.perform { + do { + let fetchRequest: NSFetchRequest = SettingEntity.fetchRequest() + fetchRequest.fetchLimit = 1 + let settingEntities = try context.fetch(fetchRequest) + let settingEntity: SettingEntity + if let existing = settingEntities.first { + settingEntity = existing + } else { + settingEntity = SettingEntity(context: context) + } + settingEntity.endpoint = endpoint + settingEntity.username = username + settingEntity.password = password + settingEntity.token = token + try context.save() + // Wenn ein Token gespeichert wird, Setup als abgeschlossen markieren + if !token.isEmpty { + self.hasFinishedSetup = true + DispatchQueue.main.async { + NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil) + } + } + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + } + } + } + func saveUsername(_ username: String) async throws { let context = coreDataManager.context diff --git a/readeck/Data/TokenProvider.swift b/readeck/Data/TokenProvider.swift index 6135d3c..960217b 100644 --- a/readeck/Data/TokenProvider.swift +++ b/readeck/Data/TokenProvider.swift @@ -2,7 +2,7 @@ import Foundation protocol TokenProvider { func getToken() async -> String? - func getEndpoint() async -> String + func getEndpoint() async -> String? func setToken(_ token: String) async func clearToken() async } @@ -29,10 +29,10 @@ class CoreDataTokenProvider: TokenProvider { return cachedSettings?.token } - func getEndpoint() async -> String { + func getEndpoint() async -> String? { await loadSettingsIfNeeded() // Basis-URL ohne /api Suffix, da es in der API-Klasse hinzugefügt wird - return cachedSettings?.endpoint ?? "https://keep.mnk.any64.de" + return cachedSettings?.endpoint } func setToken(_ token: String) async { @@ -56,4 +56,4 @@ class CoreDataTokenProvider: TokenProvider { print("Failed to clear settings: \(error)") } } -} \ No newline at end of file +} diff --git a/readeck/Domain/Model/Bookmark.swift b/readeck/Domain/Model/Bookmark.swift index f008481..cd7b34d 100644 --- a/readeck/Domain/Model/Bookmark.swift +++ b/readeck/Domain/Model/Bookmark.swift @@ -55,3 +55,12 @@ struct ImageResource { let height: Int let width: Int } + +extension Bookmark: Hashable, Identifiable { + func hash(into hasher: inout Hasher) { + hasher.combine(id) + } + static func == (lhs: Bookmark, rhs: Bookmark) -> Bool { + lhs.id == rhs.id + } +} diff --git a/readeck/Domain/Protocols/PAuthRepository.swift b/readeck/Domain/Protocols/PAuthRepository.swift index feff181..7aa5b16 100644 --- a/readeck/Domain/Protocols/PAuthRepository.swift +++ b/readeck/Domain/Protocols/PAuthRepository.swift @@ -7,7 +7,7 @@ protocol PAuthRepository { - func login(username: String, password: String) async throws -> User + func login(endpoint: String, username: String, password: String) async throws -> User func logout() async throws func getCurrentSettings() async throws -> Settings? } diff --git a/readeck/Domain/UseCase/LoginUseCase.swift b/readeck/Domain/UseCase/LoginUseCase.swift index e181bcf..a6e176f 100644 --- a/readeck/Domain/UseCase/LoginUseCase.swift +++ b/readeck/Domain/UseCase/LoginUseCase.swift @@ -1,4 +1,3 @@ - class LoginUseCase { private let repository: PAuthRepository @@ -6,7 +5,7 @@ class LoginUseCase { self.repository = repository } - func execute(username: String, password: String) async throws -> User { - return try await repository.login(username: username, password: password) + func execute(endpoint: String, username: String, password: String) async throws -> User { + return try await repository.login(endpoint: endpoint, username: username, password: password) } } diff --git a/readeck/Domain/UseCase/SaveServerSettingsUseCase.swift b/readeck/Domain/UseCase/SaveServerSettingsUseCase.swift new file mode 100644 index 0000000..7790f6b --- /dev/null +++ b/readeck/Domain/UseCase/SaveServerSettingsUseCase.swift @@ -0,0 +1,13 @@ +import Foundation + +class SaveServerSettingsUseCase { + private let repository: PSettingsRepository + + init(repository: PSettingsRepository) { + self.repository = repository + } + + func execute(endpoint: String, username: String, password: String, token: String) async throws { + try await repository.saveServerSettings(endpoint: endpoint, username: username, password: password, token: token) + } +} \ No newline at end of file diff --git a/readeck/Domain/UseCase/SearchBookmarksUseCase.swift b/readeck/Domain/UseCase/SearchBookmarksUseCase.swift new file mode 100644 index 0000000..613d2a9 --- /dev/null +++ b/readeck/Domain/UseCase/SearchBookmarksUseCase.swift @@ -0,0 +1,13 @@ +import Foundation + +class SearchBookmarksUseCase { + private let repository: PBookmarksRepository + + init(repository: PBookmarksRepository) { + self.repository = repository + } + + func execute(search: String) async throws -> BookmarksPage { + return try await repository.searchBookmarks(search: search) + } +} \ No newline at end of file diff --git a/readeck/Info.plist b/readeck/Info.plist index 0f559b5..cb46d58 100644 --- a/readeck/Info.plist +++ b/readeck/Info.plist @@ -13,5 +13,12 @@ + UILaunchScreen + + UIColorName + green + UIImageName + readeck + diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailView.swift b/readeck/UI/BookmarkDetail/BookmarkDetailView.swift index 3c70ee8..c46f109 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailView.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailView.swift @@ -7,51 +7,25 @@ struct BookmarkDetailView: View { @State private var webViewHeight: CGFloat = 300 @State private var showingFontSettings = false + private let headerHeight: CGFloat = 260 + var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 16) { - // Header mit Bild - if !viewModel.bookmarkDetail.imageUrl.isEmpty { - AsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl)) { image in - image - .resizable() - .aspectRatio(contentMode: .fill) - } placeholder: { - Rectangle() - .fill(Color.gray.opacity(0.3)) - .frame(height: 200) - } - .frame(height: 200) - .clipped() - } - - VStack(alignment: .leading, spacing: 6) { - // Titel - Text(viewModel.bookmarkDetail.title) - .font(.largeTitle) - .fontWeight(.bold) - - // Meta-Informationen - metaInfoSection - - Divider() - - // Artikel-Inhalt mit WebView - if let settings = viewModel.settings, !viewModel.articleContent.isEmpty { - WebView(htmlContent: viewModel.articleContent, settings: settings) { height in - webViewHeight = height - } - .frame(height: webViewHeight) - } else if viewModel.isLoadingArticle { - ProgressView("Lade Artikel...") - .frame(maxWidth: .infinity, alignment: .center) - .padding() + GeometryReader { geometry in + ScrollView { + ZStack(alignment: .top) { + headerView(geometry: geometry) + VStack(alignment: .leading, spacing: 16) { + Color.clear.frame(height: viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight) + titleSection + Divider().padding(.horizontal) + contentSection + Spacer(minLength: 40) } } - .padding() } + .ignoresSafeArea(edges: .top) } - .navigationBarTitleDisplayMode(.inline) + .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .navigationBarTrailing) { Button(action: { @@ -81,37 +55,95 @@ struct BookmarkDetailView: View { } } + // MARK: - ViewBuilder + + @ViewBuilder + private func headerView(geometry: GeometryProxy) -> some View { + if !viewModel.bookmarkDetail.imageUrl.isEmpty { + GeometryReader { geo in + let offset = geo.frame(in: .global).minY + ZStack(alignment: .top) { + AsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl)) { image in + image + .resizable() + .scaledToFill() + .frame(width: geometry.size.width, height: headerHeight + (offset > 0 ? offset : 0)) + .clipped() + .offset(y: (offset > 0 ? -offset : 0)) + } placeholder: { + Rectangle() + .fill(Color.gray.opacity(0.4)) + .frame(width: geometry.size.width, height: headerHeight) + } + LinearGradient( + gradient: Gradient(colors: [Color.black.opacity(0.6), Color.clear]), + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 120) + .frame(maxWidth: .infinity) + } + } + .frame(height: headerHeight) + .ignoresSafeArea(edges: .top) + } + } + + private var titleSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text(viewModel.bookmarkDetail.title) + .font(.title2) + .fontWeight(.semibold) + .foregroundColor(.primary) + .padding(.bottom, 2) + .shadow(color: Color.black.opacity(0.15), radius: 2, x: 0, y: 1) + metaInfoSection + } + .padding(.horizontal) + } + + @ViewBuilder + private var contentSection: some View { + if let settings = viewModel.settings, !viewModel.articleContent.isEmpty { + WebView(htmlContent: viewModel.articleContent, settings: settings) { height in + webViewHeight = height + } + .frame(height: webViewHeight) + .cornerRadius(14) + .padding(.horizontal) + } else if viewModel.isLoadingArticle { + ProgressView("Lade Artikel...") + .frame(maxWidth: .infinity, alignment: .center) + .padding() + } else { + Button(action: { + SafariUtil.openInSafari(url: viewModel.bookmarkDetail.url) + }) { + HStack { + Image(systemName: "safari") + Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Original Seite") + " öffnen") + } + .font(.title3.bold()) + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .padding(.horizontal) + .padding(.top, 32) + } + } + private var metaInfoSection: some View { VStack(alignment: .leading, spacing: 8) { if !viewModel.bookmarkDetail.authors.isEmpty { - HStack { - Image(systemName: "person") - Text(viewModel.bookmarkDetail.authors.joined(separator: ", ")) - .font(.subheadline) - .foregroundColor(.secondary) - } + metaRow(icon: "person", text: (viewModel.bookmarkDetail.authors.count > 1 ? "Autor:innen: " : "Autor: ") + viewModel.bookmarkDetail.authors.joined(separator: ", ")) } - - HStack { - Image(systemName: "calendar") - Text("Erstellt: \(formatDate(viewModel.bookmarkDetail.created))") - .font(.subheadline) - .foregroundColor(.secondary) - } - - HStack { - Image(systemName: "textformat") - Text("\(viewModel.bookmarkDetail.wordCount ?? 0) Wörter • \(viewModel.bookmarkDetail.readingTime ?? 0) min Lesezeit") - .font(.subheadline) - .foregroundColor(.secondary) - } - - HStack { - Image(systemName: "safari") + metaRow(icon: "calendar", text: formatDate(viewModel.bookmarkDetail.created)) + metaRow(icon: "textformat", text: "\(viewModel.bookmarkDetail.wordCount ?? 0) Wörter • \(viewModel.bookmarkDetail.readingTime ?? 0) min Lesezeit") + metaRow(icon: "safari") { Button(action: { SafariUtil.openInSafari(url: viewModel.bookmarkDetail.url) }) { - Text("Original Seite öffnen") + Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Original Seite") + " öffnen") .font(.subheadline) .foregroundColor(.secondary) } @@ -119,23 +151,36 @@ struct BookmarkDetailView: View { } } + // ViewBuilder für Meta-Infos + @ViewBuilder + private func metaRow(icon: String, text: String) -> some View { + HStack { + Image(systemName: icon) + Text(text) + .font(.subheadline) + .foregroundColor(.secondary) + } + } + + @ViewBuilder + private func metaRow(icon: String, @ViewBuilder content: () -> some View) -> some View { + HStack { + Image(systemName: icon) + content() + } + } + private func formatDate(_ dateString: String) -> String { - // Erstelle einen Formatter für das ISO8601-Format mit Millisekunden let isoFormatter = ISO8601DateFormatter() isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - - // Fallback für Format ohne Millisekunden let isoFormatterNoMillis = ISO8601DateFormatter() isoFormatterNoMillis.formatOptions = [.withInternetDateTime] - - // Versuche beide Formate var date: Date? if let parsedDate = isoFormatter.date(from: dateString) { date = parsedDate } else if let parsedDate = isoFormatterNoMillis.date(from: dateString) { date = parsedDate } - if let date = date { let displayFormatter = DateFormatter() displayFormatter.dateStyle = .medium @@ -143,7 +188,6 @@ struct BookmarkDetailView: View { displayFormatter.locale = Locale(identifier: "de_DE") return displayFormatter.string(from: date) } - return dateString } } diff --git a/readeck/UI/Bookmarks/BookmarkCardView.swift b/readeck/UI/Bookmarks/BookmarkCardView.swift index f7182f6..c708a63 100644 --- a/readeck/UI/Bookmarks/BookmarkCardView.swift +++ b/readeck/UI/Bookmarks/BookmarkCardView.swift @@ -15,15 +15,13 @@ struct BookmarkCardView: View { image .resizable() .aspectRatio(contentMode: .fill) + .frame(height: 120) } placeholder: { - Rectangle() - .fill(Color.gray.opacity(0.2)) - .overlay { - Image(systemName: "photo") - .foregroundColor(.gray) - } + Image("placeholder") + .resizable() + .aspectRatio(contentMode: .fill) + .frame(height: 120) } - .frame(height: 120) .clipShape(RoundedRectangle(cornerRadius: 8)) VStack(alignment: .leading, spacing: 4) { @@ -60,7 +58,7 @@ struct BookmarkCardView: View { } HStack { - Label("Original Seite öffnen", systemImage: "safari") + Label((URLUtil.extractDomain(from: bookmark.url) ?? "Original Seite") + " öffnen", systemImage: "safari") .onTapGesture { SafariUtil.openInSafari(url: bookmark.url) } diff --git a/readeck/UI/Bookmarks/BookmarksView.swift b/readeck/UI/Bookmarks/BookmarksView.swift index fa756ff..533225c 100644 --- a/readeck/UI/Bookmarks/BookmarksView.swift +++ b/readeck/UI/Bookmarks/BookmarksView.swift @@ -92,8 +92,6 @@ struct BookmarksView: View { ) } } - .searchable( - text: $viewModel.searchQuery, placement: .automatic, prompt: "Search...") } // FAB Button - nur bei "Ungelesen" anzeigen diff --git a/readeck/UI/DefaultUseCaseFactory.swift b/readeck/UI/DefaultUseCaseFactory.swift index 2e0b60a..f137157 100644 --- a/readeck/UI/DefaultUseCaseFactory.swift +++ b/readeck/UI/DefaultUseCaseFactory.swift @@ -11,6 +11,8 @@ protocol UseCaseFactory { func makeDeleteBookmarkUseCase() -> DeleteBookmarkUseCase func makeCreateBookmarkUseCase() -> CreateBookmarkUseCase func makeLogoutUseCase() -> LogoutUseCase + func makeSearchBookmarksUseCase() -> SearchBookmarksUseCase + func makeSaveServerSettingsUseCase() -> SaveServerSettingsUseCase } class DefaultUseCaseFactory: UseCaseFactory { @@ -68,4 +70,12 @@ class DefaultUseCaseFactory: UseCaseFactory { func makeCreateBookmarkUseCase() -> CreateBookmarkUseCase { return CreateBookmarkUseCase(repository: bookmarksRepository) } + + func makeSearchBookmarksUseCase() -> SearchBookmarksUseCase { + return SearchBookmarksUseCase(repository: bookmarksRepository) + } + + func makeSaveServerSettingsUseCase() -> SaveServerSettingsUseCase { + return SaveServerSettingsUseCase(repository: SettingsRepository()) + } } diff --git a/readeck/UI/Menu/PadSidebarView.swift b/readeck/UI/Menu/PadSidebarView.swift index 6999d5b..66d67a0 100644 --- a/readeck/UI/Menu/PadSidebarView.swift +++ b/readeck/UI/Menu/PadSidebarView.swift @@ -11,10 +11,12 @@ struct PadSidebarView: View { @State private var selectedTab: SidebarTab = .unread @State private var selectedBookmark: Bookmark? + private let sidebarTabs: [SidebarTab] = [.search, .all, .unread, .favorite, .archived, .article, .videos, .pictures, .tags] + var body: some View { NavigationSplitView { List { - ForEach(SidebarTab.allCases.filter { $0 != .settings }, id: \.self) { tab in + ForEach(sidebarTabs, id: \.self) { tab in Button(action: { selectedTab = tab }) { @@ -26,11 +28,11 @@ struct PadSidebarView: View { .listRowBackground(selectedTab == tab ? Color.accentColor.opacity(0.15) : Color.clear) if tab == .archived { - Spacer(minLength: 20) + Spacer() } if tab == .pictures { - Spacer(minLength: 30) + Spacer() Divider() Spacer() } @@ -57,6 +59,8 @@ struct PadSidebarView: View { } content: { Group { switch selectedTab { + case .search: + SearchBookmarksView(selectedBookmark: $selectedBookmark) case .all: BookmarksView(state: .all, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark) case .unread: diff --git a/readeck/UI/Menu/PhoneTabView.swift b/readeck/UI/Menu/PhoneTabView.swift index f6b223f..6f845bc 100644 --- a/readeck/UI/Menu/PhoneTabView.swift +++ b/readeck/UI/Menu/PhoneTabView.swift @@ -9,7 +9,7 @@ import SwiftUI struct PhoneTabView: View { private let mainTabs: [SidebarTab] = [.all, .unread, .favorite, .archived] - private let moreTabs: [SidebarTab] = [.article, .videos, .pictures, .tags, .settings] + private let moreTabs: [SidebarTab] = [.search, .article, .videos, .pictures, .tags, .settings] @State private var selectedMoreTab: SidebarTab? = nil @State private var selectedTabIndex: Int = 0 @@ -42,7 +42,6 @@ struct PhoneTabView: View { } .tag(mainTabs.count) .onAppear { - // Wenn der Mehr-Tab aktiv wird und wir in einer Detailansicht sind, zurücksetzen if selectedTabIndex == mainTabs.count && selectedMoreTab != nil { selectedMoreTab = nil } @@ -62,6 +61,8 @@ struct PhoneTabView: View { BookmarksView(state: .favorite, type: [.article], selectedBookmark: .constant(nil)) case .archived: BookmarksView(state: .archived, type: [.article], selectedBookmark: .constant(nil)) + case .search: + SearchBookmarksView(selectedBookmark: .constant(nil)) case .settings: SettingsView() case .article: diff --git a/readeck/UI/Menu/SidebarTab.swift b/readeck/UI/Menu/SidebarTab.swift index 424470b..a51c9b5 100644 --- a/readeck/UI/Menu/SidebarTab.swift +++ b/readeck/UI/Menu/SidebarTab.swift @@ -6,7 +6,7 @@ // enum SidebarTab: Hashable, CaseIterable, Identifiable { - case all, unread, favorite, archived, settings, article, videos, pictures, tags + case search, all, unread, favorite, archived, article, videos, pictures, tags, settings var id: Self { self } @@ -16,6 +16,7 @@ enum SidebarTab: Hashable, CaseIterable, Identifiable { case .unread: return "Ungelesen" case .favorite: return "Favoriten" case .archived: return "Archiv" + case .search: return "Suche" case .settings: return "Einstellungen" case .article: return "Artikel" case .videos: return "Videos" @@ -29,6 +30,7 @@ enum SidebarTab: Hashable, CaseIterable, Identifiable { case .unread: return "house" case .favorite: return "heart" case .archived: return "archivebox" + case .search: return "magnifyingglass" case .settings: return "gear" case .all: return "list.bullet" case .article: return "doc.plaintext" diff --git a/readeck/UI/Search/SearchBookmarksView.swift b/readeck/UI/Search/SearchBookmarksView.swift new file mode 100644 index 0000000..dd673fa --- /dev/null +++ b/readeck/UI/Search/SearchBookmarksView.swift @@ -0,0 +1,79 @@ +import SwiftUI + +struct SearchBookmarksView: View { + @State private var viewModel = SearchBookmarksViewModel() + @FocusState private var searchFieldIsFocused: Bool + @State private var selectedBookmarkId: String? + @Binding var selectedBookmark: Bookmark? + + var body: some View { + VStack(spacing: 0) { + HStack { + Image(systemName: "magnifyingglass") + .foregroundColor(.gray) + TextField("Suchbegriff eingeben...", text: $viewModel.searchQuery) + .focused($searchFieldIsFocused) + .textFieldStyle(PlainTextFieldStyle()) + .autocapitalization(.none) + .disableAutocorrection(true) + if !viewModel.searchQuery.isEmpty { + Button(action: { + viewModel.searchQuery = "" + searchFieldIsFocused = true + }) { + Image(systemName: "xmark.circle.fill") + .foregroundColor(.gray) + } + .buttonStyle(.plain) + } + } + .padding(10) + .background(Color(.systemGray6)) + .cornerRadius(12) + .padding([.horizontal, .top]) + + if viewModel.isLoading { + ProgressView("Suche...") + .padding() + } + + if let error = viewModel.errorMessage { + Text(error) + .foregroundColor(.red) + .padding() + } + + if let bookmarks = viewModel.bookmarks?.bookmarks, !bookmarks.isEmpty { + List(bookmarks) { bookmark in + Button(action: { + + if UIDevice.isPhone { + selectedBookmarkId = bookmark.id + } else { + if selectedBookmark?.id == bookmark.id { + selectedBookmark = nil + DispatchQueue.main.async { + selectedBookmark = bookmark + } + } else { + selectedBookmark = bookmark + } + } + }) { + BookmarkCardView(bookmark: bookmark, currentState: .all, onArchive: {_ in }, onDelete: {_ in }, onToggleFavorite: {_ in }) + .listRowBackground(Color(.systemBackground)) + .padding(.vertical, 4) + } + .buttonStyle(.plain) + .listRowSeparator(.hidden) + } + .listStyle(.plain) + } else if !viewModel.isLoading && viewModel.bookmarks != nil { + ContentUnavailableView("Keine Ergebnisse", systemImage: "magnifyingglass", description: Text("Keine Bookmarks gefunden.")) + .padding() + } + Spacer() + } + .navigationTitle("Suche") + } +} diff --git a/readeck/UI/Search/SearchBookmarksViewModel.swift b/readeck/UI/Search/SearchBookmarksViewModel.swift new file mode 100644 index 0000000..649329b --- /dev/null +++ b/readeck/UI/Search/SearchBookmarksViewModel.swift @@ -0,0 +1,49 @@ +import Foundation +import Combine +import SwiftUI + +@Observable +class SearchBookmarksViewModel { + private let searchBookmarksUseCase = DefaultUseCaseFactory.shared.makeSearchBookmarksUseCase() + + var searchQuery: String = "" { + didSet { + throttleSearch() + } + } + var bookmarks: BookmarksPage? = nil + var isLoading = false + var errorMessage: String? = nil + + private var searchWorkItem: DispatchWorkItem? + + private func throttleSearch() { + searchWorkItem?.cancel() + let workItem = DispatchWorkItem { [weak self] in + guard let self = self else { return } + Task { + await self.search() + } + } + searchWorkItem = workItem + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: workItem) + } + + @MainActor + func search() async { + guard !searchQuery.isEmpty else { + bookmarks = nil + return + } + isLoading = true + errorMessage = nil + do { + let result = try await searchBookmarksUseCase.execute(search: searchQuery) + bookmarks = result + } catch { + errorMessage = "Fehler bei der Suche" + bookmarks = nil + } + isLoading = false + } +} \ No newline at end of file diff --git a/readeck/UI/Settings/SettingsServerView.swift b/readeck/UI/Settings/SettingsServerView.swift index 859d024..d7a3bde 100644 --- a/readeck/UI/Settings/SettingsServerView.swift +++ b/readeck/UI/Settings/SettingsServerView.swift @@ -6,12 +6,9 @@ // import SwiftUI -// SectionHeader wird jetzt zentral importiert struct SettingsServerView: View { @State private var viewModel = SettingsServerViewModel() - @State private var isTesting: Bool = false - @State private var connectionTestSuccess: Bool = false @State private var showingLogoutAlert = false var body: some View { @@ -41,7 +38,6 @@ struct SettingsServerView: View { .onChange(of: viewModel.endpoint) { if viewModel.isSetupMode { viewModel.clearMessages() - connectionTestSuccess = false } } } @@ -56,7 +52,6 @@ struct SettingsServerView: View { .onChange(of: viewModel.username) { if viewModel.isSetupMode { viewModel.clearMessages() - connectionTestSuccess = false } } } @@ -69,7 +64,6 @@ struct SettingsServerView: View { .onChange(of: viewModel.password) { if viewModel.isSetupMode { viewModel.clearMessages() - connectionTestSuccess = false } } } @@ -106,34 +100,11 @@ struct SettingsServerView: View { } } - // Action Buttons if viewModel.isSetupMode { VStack(spacing: 10) { Button(action: { Task { - await testConnection() - } - }) { - HStack { - if isTesting { - ProgressView() - .scaleEffect(0.8) - .progressViewStyle(CircularProgressViewStyle(tint: .white)) - } - Text(isTesting ? "Teste Verbindung..." : "Verbindung testen") - .fontWeight(.semibold) - } - .frame(maxWidth: .infinity) - .padding() - .background(viewModel.canLogin ? Color.accentColor : Color.gray) - .foregroundColor(.white) - .cornerRadius(10) - } - .disabled(!viewModel.canLogin || isTesting || viewModel.isLoading) - - Button(action: { - Task { - await viewModel.login() + await viewModel.saveServerSettings() } }) { HStack { @@ -142,17 +113,16 @@ struct SettingsServerView: View { .scaleEffect(0.8) .progressViewStyle(CircularProgressViewStyle(tint: .white)) } - Text(viewModel.isLoading ? "Anmelde..." : (viewModel.isLoggedIn ? "Erneut anmelden" : "Anmelden")) + Text(viewModel.isLoading ? "Speichern..." : (viewModel.isLoggedIn ? "Erneut anmelden & speichern" : "Anmelden & speichern")) .fontWeight(.semibold) } .frame(maxWidth: .infinity) .padding() - .background((viewModel.canLogin && connectionTestSuccess) ? Color.blue : Color.gray) + .background(viewModel.canLogin ? Color.accentColor : Color.gray) .foregroundColor(.white) .cornerRadius(10) } - .disabled(!viewModel.canLogin || !connectionTestSuccess || viewModel.isLoading || isTesting) - + .disabled(!viewModel.canLogin || viewModel.isLoading) Button("Debug-Anmeldung") { viewModel.username = "admin" viewModel.password = "Diggah123" @@ -192,12 +162,6 @@ struct SettingsServerView: View { await viewModel.loadServerSettings() } } - - private func testConnection() async { - isTesting = true - connectionTestSuccess = await viewModel.testConnection() - isTesting = false - } } #Preview { diff --git a/readeck/UI/Settings/SettingsServerViewModel.swift b/readeck/UI/Settings/SettingsServerViewModel.swift index 209874b..14fe848 100644 --- a/readeck/UI/Settings/SettingsServerViewModel.swift +++ b/readeck/UI/Settings/SettingsServerViewModel.swift @@ -4,11 +4,13 @@ import SwiftUI @Observable class SettingsServerViewModel { + + // MARK: - Use Cases + private let loginUseCase: LoginUseCase private let logoutUseCase: LogoutUseCase - private let saveSettingsUseCase: SaveSettingsUseCase + private let saveServerSettingsUseCase: SaveServerSettingsUseCase private let loadSettingsUseCase: LoadSettingsUseCase - private let settingsRepository: SettingsRepository // MARK: - Server Settings var endpoint = "" @@ -16,21 +18,25 @@ class SettingsServerViewModel { var password = "" var isLoading = false var isLoggedIn = false + // MARK: - Messages var errorMessage: String? var successMessage: String? + private var hasFinishedSetup: Bool { + SettingsRepository().hasFinishedSetup + } + init() { let factory = DefaultUseCaseFactory.shared self.loginUseCase = factory.makeLoginUseCase() self.logoutUseCase = factory.makeLogoutUseCase() - self.saveSettingsUseCase = factory.makeSaveSettingsUseCase() + self.saveServerSettingsUseCase = factory.makeSaveServerSettingsUseCase() self.loadSettingsUseCase = factory.makeLoadSettingsUseCase() - self.settingsRepository = SettingsRepository() } var isSetupMode: Bool { - !settingsRepository.hasFinishedSetup + !hasFinishedSetup } @MainActor @@ -49,63 +55,25 @@ class SettingsServerViewModel { @MainActor func saveServerSettings() async { - do { - try await saveSettingsUseCase.execute( - endpoint: endpoint, - username: username, - password: password - ) - successMessage = "Server-Einstellungen gespeichert" - } catch { - errorMessage = "Fehler beim Speichern der Server-Einstellungen" - } - } - - @MainActor - func testConnection() async -> Bool { guard canLogin else { errorMessage = "Bitte füllen Sie alle Felder aus." - return false + return } - clearMessages() - - do { - // Test login without saving settings - let _ = try await loginUseCase.execute( - username: username.trimmingCharacters(in: .whitespacesAndNewlines), - password: password - ) - - - successMessage = "Verbindung erfolgreich getestet! ✓" - - return true - - } catch { - errorMessage = "Verbindungstest fehlgeschlagen: \(error.localizedDescription)" - } - - return false - } - - @MainActor - func login() async { isLoading = true - errorMessage = nil - successMessage = nil + defer { isLoading = false } do { - let _ = try await loginUseCase.execute(username: username, password: password) + let user = try await loginUseCase.execute(endpoint: endpoint, username: username.trimmingCharacters(in: .whitespacesAndNewlines), password: password) + try await saveServerSettingsUseCase.execute(endpoint: endpoint, username: username, password: password, token: user.token) isLoggedIn = true - successMessage = "Erfolgreich angemeldet" - try await settingsRepository.saveHasFinishedSetup(true) + successMessage = "Server-Einstellungen gespeichert und erfolgreich angemeldet." + try await SettingsRepository().saveHasFinishedSetup(true) NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil) await DefaultUseCaseFactory.shared.refreshConfiguration() } catch { - errorMessage = "Anmeldung fehlgeschlagen" + errorMessage = "Verbindung oder Anmeldung fehlgeschlagen: \(error.localizedDescription)" isLoggedIn = false } - isLoading = false } @MainActor @@ -125,9 +93,6 @@ class SettingsServerViewModel { successMessage = nil } - var canSave: Bool { - !endpoint.isEmpty && !username.isEmpty && !password.isEmpty - } var canLogin: Bool { !username.isEmpty && !password.isEmpty } diff --git a/readeck/Utils/SafariUtil.swift b/readeck/Utils/SafariUtil.swift index f1c9ff8..de2966f 100644 --- a/readeck/Utils/SafariUtil.swift +++ b/readeck/Utils/SafariUtil.swift @@ -23,3 +23,10 @@ class SafariUtil { } } } + +struct URLUtil { + static func extractDomain(from urlString: String) -> String? { + guard let url = URL(string: urlString), let host = url.host else { return nil } + return host.replacingOccurrences(of: "www.", with: "") + } +}