Refactor authentication, settings, and UI; add search and improve bookmark image handling

- Refactor authentication flow to require endpoint for login and decouple token saving
- Add and integrate search functionality for bookmarks
- Simplify and improve server settings setup (remove connection test, direct save & login)
- Update sidebar/tab navigation to include search and improve structure
- Show placeholder image in BookmarkCardView if no image is available, ensuring consistent layout
- Improve BookmarkDetailView header and meta info display
- Add utility for domain extraction from URLs
- General code cleanup and minor UI/UX improvements
This commit is contained in:
Ilyas Hallak 2025-07-03 21:45:53 +02:00
parent ec22c379d1
commit e88693363b
32 changed files with 537 additions and 218 deletions

View File

@ -5,17 +5,17 @@
<key>NSExtension</key>
<dict>
<key>NSExtensionAttributes</key>
<dict>
<key>NSExtensionActivationRule</key>
<dict>
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
<integer>1</integer>
</dict>
</dict>
<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>
<key>NSExtensionPrincipalClass</key>
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
</dict>
</dict>
</plist>

View File

@ -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;

View File

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

View File

@ -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
}
}

View File

@ -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
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

View File

@ -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 {

View File

@ -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)
}

View File

@ -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 {

View File

@ -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> = 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

View File

@ -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)")
}
}
}
}

View File

@ -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
}
}

View File

@ -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?
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View File

@ -13,5 +13,12 @@
</array>
</dict>
</array>
<key>UILaunchScreen</key>
<dict>
<key>UIColorName</key>
<string>green</string>
<key>UIImageName</key>
<string>readeck</string>
</dict>
</dict>
</plist>

View File

@ -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
}
}

View File

@ -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)
}

View File

@ -92,8 +92,6 @@ struct BookmarksView: View {
)
}
}
.searchable(
text: $viewModel.searchQuery, placement: .automatic, prompt: "Search...")
}
// FAB Button - nur bei "Ungelesen" anzeigen

View File

@ -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())
}
}

View File

@ -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:

View File

@ -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:

View File

@ -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"

View File

@ -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")
}
}

View File

@ -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
}
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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: "")
}
}