Compare commits

..

No commits in common. "be68538da3649cd9adf62e2be8ef4f486cb4d745" and "e5040f54e17639664b90fa38ceefec0a27bca91b" have entirely different histories.

25 changed files with 435 additions and 655 deletions

View File

@ -10,7 +10,7 @@ import Foundation
protocol PAPI { protocol PAPI {
var tokenProvider: TokenProvider { get } var tokenProvider: TokenProvider { get }
func login(username: String, password: String) async throws -> UserDto func login(username: String, password: String) async throws -> UserDto
func getBookmarks(state: BookmarkState?, limit: Int?, offset: Int?, search: String?, type: [BookmarkType]?) async throws -> BookmarksPageDto func getBookmarks(state: BookmarkState?, limit: Int?, offset: Int?, search: String?) async throws -> [BookmarkDto]
func getBookmark(id: String) async throws -> BookmarkDetailDto func getBookmark(id: String) async throws -> BookmarkDetailDto
func getBookmarkArticle(id: String) async throws -> String func getBookmarkArticle(id: String) async throws -> String
func createBookmark(createRequest: CreateBookmarkRequestDto) async throws -> CreateBookmarkResponseDto func createBookmark(createRequest: CreateBookmarkRequestDto) async throws -> CreateBookmarkResponseDto
@ -36,46 +36,6 @@ class API: PAPI {
return url return url
} }
} }
private func makeJSONRequestWithHeaders<T: Codable>(
endpoint: String,
method: HTTPMethod = .GET,
body: Data? = nil,
responseType: T.Type
) async throws -> (T, HTTPURLResponse) {
let baseURL = await self.baseURL
let fullEndpoint = endpoint.hasPrefix("/api") ? endpoint : "/api\(endpoint)"
guard let url = URL(string: "\(baseURL)\(fullEndpoint)") else {
throw APIError.invalidURL
}
var request = URLRequest(url: url)
request.httpMethod = method.rawValue
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
if let token = await tokenProvider.getToken() {
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
}
if let body = body {
request.httpBody = body
}
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 {
print("Server Error: \(httpResponse.statusCode) - \(String(data: data, encoding: .utf8) ?? "No response body")")
throw APIError.serverError(httpResponse.statusCode)
}
let decoded = try JSONDecoder().decode(T.self, from: data)
return (decoded, httpResponse)
}
// Separate Methode für JSON-Requests // Separate Methode für JSON-Requests
private func makeJSONRequest<T: Codable>( private func makeJSONRequest<T: Codable>(
@ -171,8 +131,7 @@ class API: PAPI {
return userDto return userDto
} }
// Angepasste getBookmarks-Methode mit Header-Auslesen func getBookmarks(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil, search: String? = nil) async throws -> [BookmarkDto] {
func getBookmarks(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil, search: String? = nil, type: [BookmarkType]? = nil) async throws -> BookmarksPageDto {
var endpoint = "/api/bookmarks" var endpoint = "/api/bookmarks"
var queryItems: [URLQueryItem] = [] var queryItems: [URLQueryItem] = []
@ -186,8 +145,6 @@ class API: PAPI {
queryItems.append(URLQueryItem(name: "is_marked", value: "true")) queryItems.append(URLQueryItem(name: "is_marked", value: "true"))
case .archived: case .archived:
queryItems.append(URLQueryItem(name: "is_archived", value: "true")) queryItems.append(URLQueryItem(name: "is_archived", value: "true"))
case .all:
break
} }
} }
@ -202,37 +159,15 @@ class API: PAPI {
queryItems.append(URLQueryItem(name: "search", value: search)) queryItems.append(URLQueryItem(name: "search", value: search))
} }
// type-Parameter als Array von BookmarkType
if let type = type, !type.isEmpty {
for t in type {
queryItems.append(URLQueryItem(name: "type", value: t.rawValue))
}
}
if !queryItems.isEmpty { if !queryItems.isEmpty {
let queryString = queryItems.map { "\($0.name)=\($0.value ?? "")" }.joined(separator: "&") let queryString = queryItems.map { "\($0.name)=\($0.value ?? "")" }.joined(separator: "&")
endpoint += "?\(queryString)" endpoint += "?\(queryString)"
} }
let (bookmarks, response) = try await makeJSONRequestWithHeaders( return try await makeJSONRequest(
endpoint: endpoint, endpoint: endpoint,
responseType: [BookmarkDto].self responseType: [BookmarkDto].self
) )
// Header auslesen
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
)
} }
func getBookmark(id: String) async throws -> BookmarkDetailDto { func getBookmark(id: String) async throws -> BookmarkDetailDto {

View File

@ -1,14 +0,0 @@
//
// BookmarksPageDto.swift
// readeck
//
// Created by Ilyas Hallak on 01.07.25.
//
struct BookmarksPageDto {
let bookmarks: [BookmarkDto]
let currentPage: Int?
let totalCount: Int?
let totalPages: Int?
let links: [String]?
}

View File

@ -1,17 +1,5 @@
import Foundation import Foundation
extension BookmarksPageDto {
func toDomain() -> BookmarksPage {
return BookmarksPage(
bookmarks: bookmarks.map { $0.toDomain() },
currentPage: currentPage,
totalCount: totalCount,
totalPages: totalPages,
links: links
)
}
}
// MARK: - BookmarkDto to Domain Mapping // MARK: - BookmarkDto to Domain Mapping
extension BookmarkDto { extension BookmarkDto {
func toDomain() -> Bookmark { func toDomain() -> Bookmark {
@ -70,4 +58,4 @@ extension ImageResourceDto {
func toDomain() -> ImageResource { func toDomain() -> ImageResource {
return ImageResource(src: src, height: height, width: width) return ImageResource(src: src, height: height, width: width)
} }
} }

View File

@ -1,7 +1,7 @@
import Foundation import Foundation
protocol PBookmarksRepository { protocol PBookmarksRepository {
func fetchBookmarks(state: BookmarkState?, limit: Int?, offset: Int?, search: String?, type: [BookmarkType]?) async throws -> BookmarksPage func fetchBookmarks(state: BookmarkState?, limit: Int?, offset: Int?, search: String?) async throws -> [Bookmark]
func fetchBookmark(id: String) async throws -> BookmarkDetail func fetchBookmark(id: String) async throws -> BookmarkDetail
func fetchBookmarkArticle(id: String) async throws -> String func fetchBookmarkArticle(id: String) async throws -> String
func createBookmark(createRequest: CreateBookmarkRequest) async throws -> String func createBookmark(createRequest: CreateBookmarkRequest) async throws -> String
@ -16,9 +16,9 @@ class BookmarksRepository: PBookmarksRepository {
self.api = api self.api = api
} }
func fetchBookmarks(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil, search: String? = nil, type: [BookmarkType]? = nil) async throws -> BookmarksPage { func fetchBookmarks(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil, search: String? = nil) async throws -> [Bookmark] {
let bookmarkDtos = try await api.getBookmarks(state: state, limit: limit, offset: offset, search: search, type: type) let bookmarkDtos = try await api.getBookmarks(state: state, limit: limit, offset: offset, search: search)
return bookmarkDtos.toDomain() return bookmarkDtos.map { $0.toDomain() }
} }
func fetchBookmark(id: String) async throws -> BookmarkDetail { func fetchBookmark(id: String) async throws -> BookmarkDetail {

View File

@ -1,13 +1,5 @@
import Foundation import Foundation
struct BookmarksPage {
var bookmarks: [Bookmark]
let currentPage: Int?
let totalCount: Int?
let totalPages: Int?
let links: [String]?
}
struct Bookmark { struct Bookmark {
let id: String let id: String
let title: String let title: String

View File

@ -1,7 +0,0 @@
import Foundation
public enum BookmarkType: String, CaseIterable, Codable {
case article
case photo
case video
}

View File

@ -7,14 +7,13 @@ class GetBookmarksUseCase {
self.repository = repository self.repository = repository
} }
func execute(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil, search: String? = nil, type: [BookmarkType]? = nil) async throws -> BookmarksPage { func execute(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil, search: String? = nil) async throws -> [Bookmark] {
var allBookmarks = try await repository.fetchBookmarks(state: state, limit: limit, offset: offset, search: search, type: type) let allBookmarks = try await repository.fetchBookmarks(state: state, limit: limit, offset: offset, search: search)
// Fallback-Filterung auf Client-Seite falls API keine Query-Parameter unterstützt
if let state = state { if let state = state {
allBookmarks.bookmarks = allBookmarks.bookmarks.filter { bookmark in return allBookmarks.filter { bookmark in
switch state { switch state {
case .all:
return true
case .unread: case .unread:
return !bookmark.isArchived && !bookmark.isMarked return !bookmark.isArchived && !bookmark.isMarked
case .favorite: case .favorite:

View File

@ -82,11 +82,22 @@ struct BookmarkCardView: View {
.background(Color(.systemBackground)) .background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12)) .clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1) .shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1)
// Swipe Actions hinzufügen
.swipeActions(edge: .trailing, allowsFullSwipe: true) { .swipeActions(edge: .trailing, allowsFullSwipe: true) {
// Löschen (ganz rechts)
Button("Löschen", role: .destructive) { Button("Löschen", role: .destructive) {
onDelete(bookmark) onDelete(bookmark)
} }
.tint(.red) .tint(.red)
// Favorit (rechts)
Button {
onToggleFavorite(bookmark)
} label: {
Label(bookmark.isMarked ? "Entfernen" : "Favorit",
systemImage: bookmark.isMarked ? "heart.slash" : "heart.fill")
}
.tint(bookmark.isMarked ? .gray : .pink)
} }
.swipeActions(edge: .leading, allowsFullSwipe: true) { .swipeActions(edge: .leading, allowsFullSwipe: true) {
// Archivieren (links) // Archivieren (links)
@ -100,14 +111,6 @@ struct BookmarkCardView: View {
} }
} }
.tint(currentState == .archived ? .blue : .orange) .tint(currentState == .archived ? .blue : .orange)
Button {
onToggleFavorite(bookmark)
} label: {
Label(bookmark.isMarked ? "Entfernen" : "Favorit",
systemImage: bookmark.isMarked ? "heart.slash" : "heart.fill")
}
.tint(bookmark.isMarked ? .gray : .pink)
} }
} }
@ -118,6 +121,7 @@ struct BookmarkCardView: View {
return nil return nil
} }
// Prüfe auf Unix Epoch (1970-01-01) - bedeutet "kein Datum"
if published.contains("1970-01-01") { if published.contains("1970-01-01") {
return nil return nil
} }

View File

@ -1,164 +1,154 @@
import Combine
import Foundation import Foundation
import Combine
import SwiftUI import SwiftUI
struct BookmarksView: View { struct BookmarksView: View {
// MARK: States
@State private var viewModel = BookmarksViewModel() @State private var viewModel = BookmarksViewModel()
@State private var showingAddBookmark = false @State private var showingAddBookmark = false
@State private var selectedBookmarkId: String? @State private var selectedBookmarkId: String?
let state: BookmarkState
@Binding var selectedBookmark: Bookmark?
@State private var showingAddBookmarkFromShare = false @State private var showingAddBookmarkFromShare = false
@State private var shareURL = "" @State private var shareURL = ""
@State private var shareTitle = "" @State private var shareTitle = ""
let state: BookmarkState
let type: [BookmarkType]
@Binding var selectedBookmark: Bookmark?
// MARK: Environments
@Environment(\.horizontalSizeClass) var horizontalSizeClass @Environment(\.horizontalSizeClass) var horizontalSizeClass
@Environment(\.verticalSizeClass) var verticalSizeClass @Environment(\.verticalSizeClass) var verticalSizeClass
var body: some View { var body: some View {
ZStack { NavigationStack {
if viewModel.isLoading && viewModel.bookmarks?.bookmarks.isEmpty == true { ZStack {
ProgressView("Lade \(state.displayName)...") if viewModel.isLoading && viewModel.bookmarks.isEmpty {
} else { ProgressView("Lade \(state.displayName)...")
List { } else {
ForEach(viewModel.bookmarks?.bookmarks ?? [], id: \.id) { bookmark in List {
Button(action: { ForEach(viewModel.bookmarks, id: \.id) { bookmark in
if UIDevice.isPhone { Button(action: {
selectedBookmarkId = bookmark.id if UIDevice.isPhone {
} else { selectedBookmarkId = bookmark.id
if selectedBookmark?.id == bookmark.id { } else {
selectedBookmark = nil if selectedBookmark?.id == bookmark.id {
DispatchQueue.main.async { // Optional: Deselect, um erneutes Auswählen zu ermöglichen
selectedBookmark = nil
DispatchQueue.main.async {
selectedBookmark = bookmark
}
} else {
selectedBookmark = bookmark selectedBookmark = bookmark
} }
} else {
selectedBookmark = bookmark
} }
} }) {
}) { BookmarkCardView(
BookmarkCardView( bookmark: bookmark,
bookmark: bookmark, currentState: state,
currentState: state, onArchive: { bookmark in
onArchive: { bookmark in Task {
Task { await viewModel.toggleArchive(bookmark: bookmark)
await viewModel.toggleArchive(bookmark: bookmark) }
},
onDelete: { bookmark in
Task {
await viewModel.deleteBookmark(bookmark: bookmark)
}
},
onToggleFavorite: { bookmark in
Task {
await viewModel.toggleFavorite(bookmark: bookmark)
}
} }
}, )
onDelete: { bookmark in .onAppear {
Task { if bookmark.id == viewModel.bookmarks.last?.id {
await viewModel.deleteBookmark(bookmark: bookmark) Task {
} await viewModel.loadMoreBookmarks()
}, }
onToggleFavorite: { bookmark in
Task {
await viewModel.toggleFavorite(bookmark: bookmark)
}
}
)
.onAppear {
if bookmark.id == viewModel.bookmarks?.bookmarks.last?.id {
Task {
await viewModel.loadMoreBookmarks()
} }
} }
} }
.buttonStyle(PlainButtonStyle())
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
} }
.buttonStyle(PlainButtonStyle())
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
} }
} .listStyle(.plain)
.listStyle(.plain) .refreshable {
.refreshable { await viewModel.refreshBookmarks()
await viewModel.refreshBookmarks() }
} .overlay {
.overlay { if viewModel.bookmarks.isEmpty && !viewModel.isLoading {
if viewModel.bookmarks?.bookmarks.isEmpty == true && !viewModel.isLoading { ContentUnavailableView(
ContentUnavailableView( "Keine Bookmarks",
"Keine Bookmarks", systemImage: "bookmark",
systemImage: "bookmark", description: Text("Es wurden noch keine Bookmarks in \(state.displayName.lowercased()) gefunden.")
description: Text(
"Es wurden noch keine Bookmarks in \(state.displayName.lowercased()) gefunden."
) )
) }
} }
} }
.searchable(
text: $viewModel.searchQuery, placement: .automatic, prompt: "Search...") // FAB Button - nur bei "Ungelesen" anzeigen
} if state == .unread {
VStack {
// FAB Button - nur bei "Ungelesen" anzeigen
if state == .unread {
VStack {
Spacer()
HStack {
Spacer() Spacer()
HStack {
Button(action: { Spacer()
showingAddBookmark = true
}) { Button(action: {
Image(systemName: "plus") showingAddBookmark = true
.font(.title2) }) {
.fontWeight(.semibold) Image(systemName: "plus")
.foregroundColor(.white) .font(.title2)
.frame(width: 56, height: 56) .fontWeight(.semibold)
.background(Color.accentColor) .foregroundColor(.white)
.clipShape(Circle()) .frame(width: 56, height: 56)
.shadow(color: .black.opacity(0.25), radius: 6, x: 0, y: 3) .background(Color.accentColor)
.clipShape(Circle())
.shadow(color: .black.opacity(0.25), radius: 6, x: 0, y: 3)
}
.padding(.trailing, 20)
.padding(.bottom, 20)
} }
.padding(.trailing, 20)
.padding(.bottom, 20)
} }
} }
} }
} .navigationTitle(state.displayName)
.navigationTitle(state.displayName) .navigationDestination(item: Binding<String?>(
.navigationDestination(
item: Binding<String?>(
get: { selectedBookmarkId }, get: { selectedBookmarkId },
set: { selectedBookmarkId = $0 } set: { selectedBookmarkId = $0 }
) )) { bookmarkId in
) { bookmarkId in BookmarkDetailView(bookmarkId: bookmarkId)
BookmarkDetailView(bookmarkId: bookmarkId) }
} .sheet(isPresented: $showingAddBookmark) {
.sheet(isPresented: $showingAddBookmark) {
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
}
.sheet(
isPresented: $viewModel.showingAddBookmarkFromShare,
content: {
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle) AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
} }
) .sheet(isPresented: $viewModel.showingAddBookmarkFromShare, content: {
/*.alert("Fehler", isPresented: .constant(viewModel.errorMessage != nil)) { AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
Button("OK", role: .cancel) { })
viewModel.errorMessage = nil /*.alert("Fehler", isPresented: .constant(viewModel.errorMessage != nil)) {
} Button("OK", role: .cancel) {
} message: { viewModel.errorMessage = nil
Text(viewModel.errorMessage ?? "") }
}*/ } message: {
.onAppear { Text(viewModel.errorMessage ?? "")
Task { }*/
await viewModel.loadBookmarks(state: state) .onAppear {
}
}
.onChange(of: showingAddBookmark) { oldValue, newValue in
// Refresh bookmarks when sheet is dismissed
if oldValue && !newValue {
Task { Task {
await viewModel.loadBookmarks(state: state) await viewModel.loadBookmarks(state: state)
} }
} }
.onChange(of: showingAddBookmark) { oldValue, newValue in
// Refresh bookmarks when sheet is dismissed
if oldValue && !newValue {
Task {
await viewModel.loadBookmarks(state: state)
}
}
}
} }
.searchable(text: $viewModel.searchQuery, placement: .automatic, prompt: "Search...")
} }
} }

View File

@ -8,27 +8,29 @@ class BookmarksViewModel {
private let updateBookmarkUseCase = DefaultUseCaseFactory.shared.makeUpdateBookmarkUseCase() private let updateBookmarkUseCase = DefaultUseCaseFactory.shared.makeUpdateBookmarkUseCase()
private let deleteBookmarkUseCase = DefaultUseCaseFactory.shared.makeDeleteBookmarkUseCase() private let deleteBookmarkUseCase = DefaultUseCaseFactory.shared.makeDeleteBookmarkUseCase()
var bookmarks: BookmarksPage? var bookmarks: [Bookmark] = []
var isLoading = false var isLoading = false
var errorMessage: String? var errorMessage: String?
var currentState: BookmarkState = .unread var currentState: BookmarkState = .unread
var type = [BookmarkType.article]
var showingAddBookmarkFromShare = false var showingAddBookmarkFromShare = false
var shareURL = "" var shareURL = ""
var shareTitle = "" var shareTitle = ""
private var cancellables = Set<AnyCancellable>() private var cancellables = Set<AnyCancellable>()
// Pagination-Variablen
private var limit = 20 private var limit = 20
private var offset = 0 private var offset = 0
private var hasMoreData = true private var hasMoreData = true
private var searchWorkItem: DispatchWorkItem?
var searchQuery: String = "" { var searchQuery: String = "" {
didSet { didSet {
throttleSearch() throttleSearch()
} }
} }
private var searchWorkItem: DispatchWorkItem?
init() { init() {
setupNotificationObserver() setupNotificationObserver()
@ -86,14 +88,13 @@ class BookmarksViewModel {
state: state, state: state,
limit: limit, limit: limit,
offset: offset, offset: offset,
search: searchQuery, search: searchQuery // Suche integrieren
type: type
) )
bookmarks = newBookmarks bookmarks = newBookmarks
hasMoreData = newBookmarks.bookmarks.count == limit // Prüfen, ob weitere Daten verfügbar sind hasMoreData = newBookmarks.count == limit // Prüfen, ob weitere Daten verfügbar sind
} catch { } catch {
errorMessage = "Fehler beim Laden der Bookmarks" errorMessage = "Fehler beim Laden der Bookmarks"
bookmarks = nil bookmarks = []
} }
isLoading = false isLoading = false
@ -109,8 +110,8 @@ class BookmarksViewModel {
do { do {
offset += limit // Offset erhöhen offset += limit // Offset erhöhen
let newBookmarks = try await getBooksmarksUseCase.execute(state: currentState, limit: limit, offset: offset) let newBookmarks = try await getBooksmarksUseCase.execute(state: currentState, limit: limit, offset: offset)
bookmarks?.bookmarks.append(contentsOf: newBookmarks.bookmarks) // Neue Bookmarks hinzufügen bookmarks.append(contentsOf: newBookmarks) // Neue Bookmarks hinzufügen
hasMoreData = newBookmarks.bookmarks.count == limit // Prüfen, hasMoreData = newBookmarks.count == limit // Prüfen, ob weitere Daten verfügbar sind
} catch { } catch {
errorMessage = "Fehler beim Nachladen der Bookmarks" errorMessage = "Fehler beim Nachladen der Bookmarks"
} }
@ -162,7 +163,7 @@ class BookmarksViewModel {
try await deleteBookmarkUseCase.execute(bookmarkId: bookmark.id) try await deleteBookmarkUseCase.execute(bookmarkId: bookmark.id)
// Lokal aus der Liste entfernen (optimistische Update) // Lokal aus der Liste entfernen (optimistische Update)
bookmarks?.bookmarks.removeAll { $0.id == bookmark.id } bookmarks.removeAll { $0.id == bookmark.id }
} catch { } catch {
errorMessage = "Fehler beim Löschen des Bookmarks" errorMessage = "Fehler beim Löschen des Bookmarks"

View File

@ -0,0 +1,7 @@
//
// File.swift
// readeck
//
// Created by Ilyas Hallak on 25.06.25.
//

View File

@ -1,39 +0,0 @@
//
// BookmarkState.swift
// readeck
//
// Created by Ilyas Hallak on 01.07.25.
//
enum BookmarkState: String, CaseIterable {
case all = "all"
case unread = "unread"
case favorite = "favorite"
case archived = "archived"
var displayName: String {
switch self {
case .all:
return "Alle"
case .unread:
return "Ungelesen"
case .favorite:
return "Favoriten"
case .archived:
return "Archiv"
}
}
var systemImage: String {
switch self {
case .all:
return "list.bullet"
case .unread:
return "house"
case .favorite:
return "heart"
case .archived:
return "archivebox"
}
}
}

View File

@ -1,87 +0,0 @@
//
// PadSidebarView.swift
// readeck
//
// Created by Ilyas Hallak on 01.07.25.
//
import SwiftUI
struct PadSidebarView: View {
@State private var selectedTab: SidebarTab = .unread
@State private var selectedBookmark: Bookmark?
var body: some View {
NavigationSplitView {
List {
ForEach(SidebarTab.allCases.filter { $0 != .settings }, id: \.self) { tab in
Button(action: {
selectedTab = tab
}) {
Label(tab.label, systemImage: tab.systemImage)
.foregroundColor(selectedTab == tab ? .accentColor : .primary)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
if tab == .archived {
Spacer(minLength: 20)
}
if tab == .pictures {
Spacer(minLength: 30)
Divider()
Spacer()
}
}
.listRowBackground(selectedTab == tab ? Color.accentColor.opacity(0.15) : Color.clear)
}
}
.listStyle(.sidebar)
.safeAreaInset(edge: .bottom, alignment: .center) {
VStack(spacing: 0) {
Divider()
Button(action: {
selectedTab = .settings
}) {
Label(SidebarTab.settings.label, systemImage: SidebarTab.settings.systemImage)
.foregroundColor(selectedTab == .settings ? .accentColor : .primary)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.vertical, 12)
.contentShape(Rectangle())
}
.listRowBackground(selectedTab == .settings ? Color.accentColor.opacity(0.15) : Color.clear)
}
.padding(.horizontal, 12)
.background(Color(.systemGroupedBackground))
}
} content: {
switch selectedTab {
case .all:
BookmarksView(state: .all, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark)
case .unread:
BookmarksView(state: .unread, type: [.article], selectedBookmark: $selectedBookmark)
case .favorite:
BookmarksView(state: .favorite, type: [.article], selectedBookmark: $selectedBookmark)
case .archived:
BookmarksView(state: .archived, type: [.article], selectedBookmark: $selectedBookmark)
case .settings:
SettingsView()
case .article:
BookmarksView(state: .all, type: [.article], selectedBookmark: $selectedBookmark)
case .videos:
BookmarksView(state: .all, type: [.video], selectedBookmark: $selectedBookmark)
case .pictures:
BookmarksView(state: .all, type: [.photo], selectedBookmark: $selectedBookmark)
case .tags:
Text("Tags")
}
} detail: {
if let bookmark = selectedBookmark, selectedTab != .settings {
BookmarkDetailView(bookmarkId: bookmark.id)
} else {
Text(selectedTab == .settings ? "" : "Select a bookmark")
.foregroundColor(.gray)
}
}
}
}

View File

@ -1,45 +0,0 @@
//
// PhoneTabView.swift
// readeck
//
// Created by Ilyas Hallak on 01.07.25.
//
import SwiftUI
struct PhoneTabView: View {
var body: some View {
TabView {
NavigationStack {
BookmarksView(state: .unread, type: [.article, .video, .photo], selectedBookmark: .constant(nil))
}
.tabItem {
Label("Alle", systemImage: "list.bullet")
}
NavigationStack {
BookmarksView(state: .unread, type: [.article], selectedBookmark: .constant(nil))
}
.tabItem {
Label("Ungelesen", systemImage: "house")
}
BookmarksView(state: .favorite, type: [.article], selectedBookmark: .constant(nil))
.tabItem {
Label("Favoriten", systemImage: "heart")
}
BookmarksView(state: .archived, type: [.article], selectedBookmark: .constant(nil))
.tabItem {
Label("Archiv", systemImage: "archivebox")
}
SettingsView()
.tabItem {
Label("Settings", systemImage: "gear")
}
}
.accentColor(.accentColor)
}
}

View File

@ -1,40 +0,0 @@
//
// SidebarTab.swift
// readeck
//
// Created by Ilyas Hallak on 01.07.25.
//
enum SidebarTab: Hashable, CaseIterable, Identifiable {
case all, unread, favorite, archived, settings, article, videos, pictures, tags
var id: Self { self }
var label: String {
switch self {
case .all: return "Alle"
case .unread: return "Ungelesen"
case .favorite: return "Favoriten"
case .archived: return "Archiv"
case .settings: return "Einstellungen"
case .article: return "Artikel"
case .videos: return "Videos"
case .pictures: return "Bilder"
case .tags: return "Tags"
}
}
var systemImage: String {
switch self {
case .unread: return "house"
case .favorite: return "heart"
case .archived: return "archivebox"
case .settings: return "gear"
case .all: return "list.bullet"
case .article: return "doc.plaintext"
case .videos: return "film"
case .pictures: return "photo"
case .tags: return "tag"
}
}
}

View File

@ -1,26 +0,0 @@
import SwiftUI
import Foundation
struct MainTabView: View {
@State private var selectedTab: SidebarTab = .unread
@State var selectedBookmark: Bookmark?
// sizeClass
@Environment(\.horizontalSizeClass)
var horizontalSizeClass
@Environment(\.verticalSizeClass)
var verticalSizeClass
var body: some View {
if UIDevice.isPhone {
PhoneTabView()
} else {
PadSidebarView()
}
}
}
#Preview {
MainTabView()
}

View File

@ -8,23 +8,31 @@
import SwiftUI import SwiftUI
struct SettingsContainerView: View { struct SettingsContainerView: View {
@State private var viewModel = SettingsViewModel()
var body: some View { var body: some View {
ScrollView { NavigationView {
LazyVStack(spacing: 20) { ScrollView {
SettingsServerView() LazyVStack(spacing: 20) {
.cardStyle() // Server-Card immer anzeigen
SettingsServerView(viewModel: viewModel)
FontSettingsView() .cardStyle()
.cardStyle()
// Allgemeine Einstellungen nur im normalen Modus anzeigen
SettingsGeneralView() if !viewModel.isSetupMode {
.cardStyle() SettingsGeneralView(viewModel: viewModel)
.cardStyle()
}
}
.padding()
.background(Color(.systemGroupedBackground))
} }
.padding() .navigationTitle("Einstellungen")
.background(Color(.systemGroupedBackground)) .navigationBarTitleDisplayMode(.large)
}
.task {
await viewModel.loadSettings()
} }
.navigationTitle("Einstellungen")
.navigationBarTitleDisplayMode(.large)
} }
} }

View File

@ -9,7 +9,7 @@ import SwiftUI
// SectionHeader wird jetzt zentral importiert // SectionHeader wird jetzt zentral importiert
struct SettingsGeneralView: View { struct SettingsGeneralView: View {
@State private var viewModel = SettingsGeneralViewModel() @State var viewModel: SettingsViewModel
var body: some View { var body: some View {
VStack(spacing: 20) { VStack(spacing: 20) {
@ -28,6 +28,10 @@ struct SettingsGeneralView: View {
.pickerStyle(.segmented) .pickerStyle(.segmented)
} }
// Font Settings
FontSettingsView()
.padding(.vertical, 4)
// Sync Settings // Sync Settings
VStack(alignment: .leading, spacing: 12) { VStack(alignment: .leading, spacing: 12) {
Text("Sync-Einstellungen") Text("Sync-Einstellungen")
@ -114,19 +118,26 @@ struct SettingsGeneralView: View {
// Save Button // Save Button
Button(action: { Button(action: {
Task { Task {
await viewModel.saveGeneralSettings() await viewModel.saveSettings()
} }
}) { }) {
HStack { HStack {
Text("Einstellungen speichern") if viewModel.isSaving {
ProgressView()
.scaleEffect(0.8)
.progressViewStyle(CircularProgressViewStyle(tint: .white))
}
Text(viewModel.isSaving ? "Speichere..." : "Einstellungen speichern")
.fontWeight(.semibold) .fontWeight(.semibold)
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding() .padding()
.background(Color.accentColor) .background(viewModel.canSave ? Color.accentColor : Color.gray)
.foregroundColor(.white) .foregroundColor(.white)
.cornerRadius(10) .cornerRadius(10)
} }
.disabled(!viewModel.canSave || viewModel.isSaving)
// Messages // Messages
if let successMessage = viewModel.successMessage { if let successMessage = viewModel.successMessage {
HStack { HStack {
@ -147,27 +158,9 @@ struct SettingsGeneralView: View {
} }
} }
} }
.task {
await viewModel.loadGeneralSettings()
}
} }
} }
enum Theme: String, CaseIterable {
case system = "system"
case light = "light"
case dark = "dark"
var displayName: String {
switch self {
case .system: return "System"
case .light: return "Hell"
case .dark: return "Dunkel"
}
}
}
#Preview { #Preview {
SettingsGeneralView() SettingsGeneralView(viewModel: SettingsViewModel())
} }

View File

@ -1,77 +0,0 @@
import Foundation
import Observation
import SwiftUI
@Observable
class SettingsGeneralViewModel {
private let saveSettingsUseCase: SaveSettingsUseCase
private let loadSettingsUseCase: LoadSettingsUseCase
// MARK: - UI Settings
var selectedTheme: Theme = .system
// MARK: - Sync Settings
var autoSyncEnabled: Bool = true
var syncInterval: Int = 15
// MARK: - Reading Settings
var enableReaderMode: Bool = false
var openExternalLinksInApp: Bool = true
var autoMarkAsRead: Bool = false
// MARK: - App Info
var appVersion: String = "1.0.0"
var developerName: String = "Your Name"
// MARK: - Messages
var errorMessage: String?
var successMessage: String?
// MARK: - Data Management (Platzhalter)
// func clearCache() async {}
// func resetSettings() async {}
init() {
let factory = DefaultUseCaseFactory.shared
self.saveSettingsUseCase = factory.makeSaveSettingsUseCase()
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
}
@MainActor
func loadGeneralSettings() async {
do {
if let settings = try await loadSettingsUseCase.execute() {
selectedTheme = .system // settings.theme ?? .system
autoSyncEnabled = false // settings.autoSyncEnabled
// syncInterval = settings.syncInterval
// enableReaderMode = settings.enableReaderMode
// openExternalLinksInApp = settings.openExternalLinksInApp
// autoMarkAsRead = settings.autoMarkAsRead
appVersion = "1.0.0"
developerName = "Ilyas Hallak"
}
} catch {
errorMessage = "Fehler beim Laden der Einstellungen"
}
}
@MainActor
func saveGeneralSettings() async {
do {
// TODO: add save general settings here
/*try await saveSettingsUseCase.execute(
token: "",
selectedTheme: selectedTheme,
autoSyncEnabled: autoSyncEnabled,
syncInterval: syncInterval,
enableReaderMode: enableReaderMode,
openExternalLinksInApp: openExternalLinksInApp,
autoMarkAsRead: autoMarkAsRead
)*/
successMessage = "Einstellungen gespeichert"
} catch {
errorMessage = "Fehler beim Speichern der Einstellungen"
}
}
func clearMessages() {
errorMessage = nil
successMessage = nil
}
}

View File

@ -9,7 +9,7 @@ import SwiftUI
// SectionHeader wird jetzt zentral importiert // SectionHeader wird jetzt zentral importiert
struct SettingsServerView: View { struct SettingsServerView: View {
@State private var viewModel = SettingsServerViewModel() @State var viewModel = SettingsViewModel()
@State private var isTesting: Bool = false @State private var isTesting: Bool = false
@State private var connectionTestSuccess: Bool = false @State private var connectionTestSuccess: Bool = false
@State private var showingLogoutAlert = false @State private var showingLogoutAlert = false
@ -188,18 +188,38 @@ struct SettingsServerView: View {
} message: { } message: {
Text("Möchten Sie sich wirklich abmelden? Dies wird alle Ihre Anmeldedaten löschen und Sie zur Einrichtung zurückführen.") Text("Möchten Sie sich wirklich abmelden? Dies wird alle Ihre Anmeldedaten löschen und Sie zur Einrichtung zurückführen.")
} }
.task {
await viewModel.loadServerSettings()
}
} }
private func testConnection() async { private func testConnection() async {
guard viewModel.canLogin else {
viewModel.errorMessage = "Bitte füllen Sie alle Felder aus."
return
}
isTesting = true isTesting = true
connectionTestSuccess = await viewModel.testConnection() viewModel.clearMessages()
connectionTestSuccess = false
do {
// Test login without saving settings
let _ = try await viewModel.loginUseCase.execute(
username: viewModel.username.trimmingCharacters(in: .whitespacesAndNewlines),
password: viewModel.password
)
// If we get here, the test was successful
connectionTestSuccess = true
viewModel.successMessage = "Verbindung erfolgreich getestet! ✓"
} catch {
connectionTestSuccess = false
viewModel.errorMessage = "Verbindungstest fehlgeschlagen: \(error.localizedDescription)"
}
isTesting = false isTesting = false
} }
} }
#Preview { #Preview {
SettingsServerView() SettingsServerView(viewModel: SettingsViewModel())
} }

View File

@ -3,11 +3,11 @@ import Observation
import SwiftUI import SwiftUI
@Observable @Observable
class SettingsServerViewModel { class SettingsViewModel {
private let loginUseCase: LoginUseCase private let _loginUseCase: LoginUseCase
private let logoutUseCase: LogoutUseCase
private let saveSettingsUseCase: SaveSettingsUseCase private let saveSettingsUseCase: SaveSettingsUseCase
private let loadSettingsUseCase: LoadSettingsUseCase private let loadSettingsUseCase: LoadSettingsUseCase
private let logoutUseCase: LogoutUseCase
private let settingsRepository: SettingsRepository private let settingsRepository: SettingsRepository
// MARK: - Server Settings // MARK: - Server Settings
@ -15,17 +15,36 @@ class SettingsServerViewModel {
var username = "" var username = ""
var password = "" var password = ""
var isLoading = false var isLoading = false
var isSaving = false
var isLoggedIn = false var isLoggedIn = false
// MARK: - UI Settings
var selectedTheme: Theme = .system
// MARK: - Sync Settings
var autoSyncEnabled: Bool = true
var syncInterval: Int = 15
// MARK: - Reading Settings
var enableReaderMode: Bool = false
var openExternalLinksInApp: Bool = true
var autoMarkAsRead: Bool = false
// MARK: - App Info
var appVersion: String = "1.0.0"
var developerName: String = "Your Name"
// MARK: - Messages // MARK: - Messages
var errorMessage: String? var errorMessage: String?
var successMessage: String? var successMessage: String?
init() { init() {
let factory = DefaultUseCaseFactory.shared let factory = DefaultUseCaseFactory.shared
self.loginUseCase = factory.makeLoginUseCase() self._loginUseCase = factory.makeLoginUseCase()
self.logoutUseCase = factory.makeLogoutUseCase()
self.saveSettingsUseCase = factory.makeSaveSettingsUseCase() self.saveSettingsUseCase = factory.makeSaveSettingsUseCase()
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase() self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
self.logoutUseCase = factory.makeLogoutUseCase()
self.settingsRepository = SettingsRepository() self.settingsRepository = SettingsRepository()
} }
@ -34,7 +53,7 @@ class SettingsServerViewModel {
} }
@MainActor @MainActor
func loadServerSettings() async { func loadSettings() async {
do { do {
if let settings = try await loadSettingsUseCase.execute() { if let settings = try await loadSettingsUseCase.execute() {
endpoint = settings.endpoint ?? "" endpoint = settings.endpoint ?? ""
@ -48,45 +67,27 @@ class SettingsServerViewModel {
} }
@MainActor @MainActor
func saveServerSettings() async { func saveSettings() async {
isSaving = true
errorMessage = nil
successMessage = nil
do { do {
try await saveSettingsUseCase.execute( try await saveSettingsUseCase.execute(
endpoint: endpoint, endpoint: endpoint,
username: username, username: username,
password: password password: password
) )
successMessage = "Server-Einstellungen gespeichert" successMessage = "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
}
clearMessages()
do {
// Test login without saving settings
let _ = try await loginUseCase.execute(
username: username.trimmingCharacters(in: .whitespacesAndNewlines),
password: password
)
// Factory-Konfiguration aktualisieren
successMessage = "Verbindung erfolgreich getestet! ✓" await DefaultUseCaseFactory.shared.refreshConfiguration()
return true
} catch { } catch {
errorMessage = "Verbindungstest fehlgeschlagen: \(error.localizedDescription)" errorMessage = "Fehler beim Speichern der Einstellungen"
} }
return false isSaving = false
} }
@MainActor @MainActor
@ -94,17 +95,27 @@ class SettingsServerViewModel {
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
successMessage = nil successMessage = nil
do { do {
let _ = try await loginUseCase.execute(username: username, password: password) let user = try await _loginUseCase.execute(username: username, password: password)
isLoggedIn = true isLoggedIn = true
successMessage = "Erfolgreich angemeldet" successMessage = "Erfolgreich angemeldet"
// Setup als abgeschlossen markieren
try await settingsRepository.saveHasFinishedSetup(true) try await settingsRepository.saveHasFinishedSetup(true)
// Notification senden, dass sich der Setup-Status geändert hat
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil) NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
// Factory-Konfiguration aktualisieren (Token wird automatisch gespeichert)
await DefaultUseCaseFactory.shared.refreshConfiguration() await DefaultUseCaseFactory.shared.refreshConfiguration()
} catch { } catch {
errorMessage = "Anmeldung fehlgeschlagen" errorMessage = "Anmeldung fehlgeschlagen"
isLoggedIn = false isLoggedIn = false
} }
isLoading = false isLoading = false
} }
@ -114,7 +125,10 @@ class SettingsServerViewModel {
try await logoutUseCase.execute() try await logoutUseCase.execute()
isLoggedIn = false isLoggedIn = false
successMessage = "Abgemeldet" successMessage = "Abgemeldet"
// Notification senden, dass sich der Setup-Status geändert hat
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil) NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
} catch { } catch {
errorMessage = "Fehler beim Abmelden" errorMessage = "Fehler beim Abmelden"
} }
@ -128,7 +142,28 @@ class SettingsServerViewModel {
var canSave: Bool { var canSave: Bool {
!endpoint.isEmpty && !username.isEmpty && !password.isEmpty !endpoint.isEmpty && !username.isEmpty && !password.isEmpty
} }
var canLogin: Bool { var canLogin: Bool {
!username.isEmpty && !password.isEmpty !username.isEmpty && !password.isEmpty
} }
}
// Expose loginUseCase for testing purposes
var loginUseCase: LoginUseCase {
return _loginUseCase
}
}
enum Theme: String, CaseIterable {
case system = "system"
case light = "light"
case dark = "dark"
var displayName: String {
switch self {
case .system: return "System"
case .light: return "Hell"
case .dark: return "Dunkel"
}
}
}

144
readeck/UI/TabView.swift Normal file
View File

@ -0,0 +1,144 @@
import SwiftUI
import Foundation
enum BookmarkState: String, CaseIterable {
case unread = "unread"
case favorite = "favorite"
case archived = "archived"
var displayName: String {
switch self {
case .unread:
return "Ungelesen"
case .favorite:
return "Favoriten"
case .archived:
return "Archiv"
}
}
var systemImage: String {
switch self {
case .unread:
return "house"
case .favorite:
return "heart"
case .archived:
return "archivebox"
}
}
}
struct MainTabView: View {
@State private var selectedTab: String = "Ungelesen"
// sizeClass
@Environment(\.horizontalSizeClass)
var horizontalSizeClass
@Environment(\.verticalSizeClass)
var verticalSizeClass
@State var selectedBookmark: Bookmark?
var body: some View {
if UIDevice.isPhone {
PhoneView()
} else {
PadView()
}
}
@ViewBuilder
private func PhoneView() -> some View {
TabView(selection: $selectedTab) {
BookmarksView(state: .unread, selectedBookmark: .constant(nil))
.tabItem {
Label("Ungelesen", systemImage: "house")
}
.tag("Ungelesen")
BookmarksView(state: .favorite, selectedBookmark: .constant(nil))
.tabItem {
Label("Favoriten", systemImage: "heart")
}
.tag("Favoriten")
BookmarksView(state: .archived, selectedBookmark: .constant(nil))
.tabItem {
Label("Archiv", systemImage: "archivebox")
}
.tag("Archiv")
SettingsView()
.tabItem {
Label("Settings", systemImage: "gear")
}
.tag("Settings")
}
.accentColor(.accentColor)
}
@ViewBuilder
private func PadView() -> some View {
TabView(selection: $selectedTab) {
// Ungelesen Tab
NavigationSplitView {
BookmarksView(state: .unread, selectedBookmark: $selectedBookmark)
} detail: {
if let selectedBookmark = selectedBookmark {
BookmarkDetailView(bookmarkId: selectedBookmark.id)
} else {
Text("Select a bookmark")
.foregroundColor(.gray)
}
}
.tabItem {
Label("Unread", systemImage: "house")
}
.tag("Unread")
NavigationSplitViewContainer(state: .favorite, selectedBookmark: $selectedBookmark)
.tabItem {
Label("Favoriten", systemImage: "heart")
}
.tag("Favorite")
NavigationSplitViewContainer(state: .archived, selectedBookmark: $selectedBookmark)
.tabItem {
Label("Archive", systemImage: "archivebox")
}
.tag("Archive")
SettingsView()
.tabItem {
Label("Settings", systemImage: "gear")
}
.tag("Settings")
}
.accentColor(.accentColor)
}
}
// Container für NavigationSplitView
struct NavigationSplitViewContainer: View {
let state: BookmarkState
@Binding var selectedBookmark: Bookmark?
var body: some View {
NavigationSplitView {
BookmarksView(state: state, selectedBookmark: $selectedBookmark)
} detail: {
if let selectedBookmark = selectedBookmark {
BookmarkDetailView(bookmarkId: selectedBookmark.id)
} else {
Text("Select a bookmark")
.foregroundColor(.gray)
}
}
}
}
#Preview {
MainTabView()
}

View File

@ -20,8 +20,7 @@ struct readeckApp: App {
MainTabView() MainTabView()
.environment(\.managedObjectContext, persistenceController.container.viewContext) .environment(\.managedObjectContext, persistenceController.container.viewContext)
} else { } else {
SettingsServerView() SettingsContainerView()
.padding()
} }
} }
.onOpenURL { url in .onOpenURL { url in