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 {
var tokenProvider: TokenProvider { get }
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 getBookmarkArticle(id: String) async throws -> String
func createBookmark(createRequest: CreateBookmarkRequestDto) async throws -> CreateBookmarkResponseDto
@ -37,46 +37,6 @@ class API: PAPI {
}
}
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
private func makeJSONRequest<T: Codable>(
endpoint: String,
@ -171,8 +131,7 @@ class API: PAPI {
return userDto
}
// Angepasste getBookmarks-Methode mit Header-Auslesen
func getBookmarks(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil, search: String? = nil, type: [BookmarkType]? = nil) async throws -> BookmarksPageDto {
func getBookmarks(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil, search: String? = nil) async throws -> [BookmarkDto] {
var endpoint = "/api/bookmarks"
var queryItems: [URLQueryItem] = []
@ -186,8 +145,6 @@ class API: PAPI {
queryItems.append(URLQueryItem(name: "is_marked", value: "true"))
case .archived:
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))
}
// 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 {
let queryString = queryItems.map { "\($0.name)=\($0.value ?? "")" }.joined(separator: "&")
endpoint += "?\(queryString)"
}
let (bookmarks, response) = try await makeJSONRequestWithHeaders(
return try await makeJSONRequest(
endpoint: endpoint,
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 {

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
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
extension BookmarkDto {
func toDomain() -> Bookmark {

View File

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

View File

@ -1,13 +1,5 @@
import Foundation
struct BookmarksPage {
var bookmarks: [Bookmark]
let currentPage: Int?
let totalCount: Int?
let totalPages: Int?
let links: [String]?
}
struct Bookmark {
let id: 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
}
func execute(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil, search: String? = nil, type: [BookmarkType]? = nil) async throws -> BookmarksPage {
var allBookmarks = try await repository.fetchBookmarks(state: state, limit: limit, offset: offset, search: search, type: type)
func execute(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil, search: String? = nil) async throws -> [Bookmark] {
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 {
allBookmarks.bookmarks = allBookmarks.bookmarks.filter { bookmark in
return allBookmarks.filter { bookmark in
switch state {
case .all:
return true
case .unread:
return !bookmark.isArchived && !bookmark.isMarked
case .favorite:

View File

@ -82,11 +82,22 @@ struct BookmarkCardView: View {
.background(Color(.systemBackground))
.clipShape(RoundedRectangle(cornerRadius: 12))
.shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1)
// Swipe Actions hinzufügen
.swipeActions(edge: .trailing, allowsFullSwipe: true) {
// Löschen (ganz rechts)
Button("Löschen", role: .destructive) {
onDelete(bookmark)
}
.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) {
// Archivieren (links)
@ -100,14 +111,6 @@ struct BookmarkCardView: View {
}
}
.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
}
// Prüfe auf Unix Epoch (1970-01-01) - bedeutet "kein Datum"
if published.contains("1970-01-01") {
return nil
}

View File

@ -1,164 +1,154 @@
import Combine
import Foundation
import Combine
import SwiftUI
struct BookmarksView: View {
// MARK: States
@State private var viewModel = BookmarksViewModel()
@State private var showingAddBookmark = false
@State private var selectedBookmarkId: String?
@State private var showingAddBookmarkFromShare = false
@State private var shareURL = ""
@State private var shareTitle = ""
let state: BookmarkState
let type: [BookmarkType]
@Binding var selectedBookmark: Bookmark?
// MARK: Environments
@State private var showingAddBookmarkFromShare = false
@State private var shareURL = ""
@State private var shareTitle = ""
@Environment(\.horizontalSizeClass) var horizontalSizeClass
@Environment(\.verticalSizeClass) var verticalSizeClass
var body: some View {
ZStack {
if viewModel.isLoading && viewModel.bookmarks?.bookmarks.isEmpty == true {
ProgressView("Lade \(state.displayName)...")
} else {
List {
ForEach(viewModel.bookmarks?.bookmarks ?? [], id: \.id) { bookmark in
Button(action: {
if UIDevice.isPhone {
selectedBookmarkId = bookmark.id
} else {
if selectedBookmark?.id == bookmark.id {
selectedBookmark = nil
DispatchQueue.main.async {
NavigationStack {
ZStack {
if viewModel.isLoading && viewModel.bookmarks.isEmpty {
ProgressView("Lade \(state.displayName)...")
} else {
List {
ForEach(viewModel.bookmarks, id: \.id) { bookmark in
Button(action: {
if UIDevice.isPhone {
selectedBookmarkId = bookmark.id
} else {
if selectedBookmark?.id == bookmark.id {
// Optional: Deselect, um erneutes Auswählen zu ermöglichen
selectedBookmark = nil
DispatchQueue.main.async {
selectedBookmark = bookmark
}
} else {
selectedBookmark = bookmark
}
} else {
selectedBookmark = bookmark
}
}
}) {
BookmarkCardView(
bookmark: bookmark,
currentState: state,
onArchive: { bookmark in
Task {
await viewModel.toggleArchive(bookmark: bookmark)
}) {
BookmarkCardView(
bookmark: bookmark,
currentState: state,
onArchive: { bookmark in
Task {
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
Task {
await viewModel.deleteBookmark(bookmark: bookmark)
}
},
onToggleFavorite: { bookmark in
Task {
await viewModel.toggleFavorite(bookmark: bookmark)
}
}
)
.onAppear {
if bookmark.id == viewModel.bookmarks?.bookmarks.last?.id {
Task {
await viewModel.loadMoreBookmarks()
)
.onAppear {
if bookmark.id == viewModel.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)
.refreshable {
await viewModel.refreshBookmarks()
}
.overlay {
if viewModel.bookmarks?.bookmarks.isEmpty == true && !viewModel.isLoading {
ContentUnavailableView(
"Keine Bookmarks",
systemImage: "bookmark",
description: Text(
"Es wurden noch keine Bookmarks in \(state.displayName.lowercased()) gefunden."
.listStyle(.plain)
.refreshable {
await viewModel.refreshBookmarks()
}
.overlay {
if viewModel.bookmarks.isEmpty && !viewModel.isLoading {
ContentUnavailableView(
"Keine Bookmarks",
systemImage: "bookmark",
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 {
Spacer()
HStack {
}
// FAB Button - nur bei "Ungelesen" anzeigen
if state == .unread {
VStack {
Spacer()
HStack {
Spacer()
Button(action: {
showingAddBookmark = true
}) {
Image(systemName: "plus")
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(.white)
.frame(width: 56, height: 56)
.background(Color.accentColor)
.clipShape(Circle())
.shadow(color: .black.opacity(0.25), radius: 6, x: 0, y: 3)
Button(action: {
showingAddBookmark = true
}) {
Image(systemName: "plus")
.font(.title2)
.fontWeight(.semibold)
.foregroundColor(.white)
.frame(width: 56, height: 56)
.background(Color.accentColor)
.clipShape(Circle())
.shadow(color: .black.opacity(0.25), radius: 6, x: 0, y: 3)
}
.padding(.trailing, 20)
.padding(.bottom, 20)
}
.padding(.trailing, 20)
.padding(.bottom, 20)
}
}
}
}
.navigationTitle(state.displayName)
.navigationDestination(
item: Binding<String?>(
.navigationTitle(state.displayName)
.navigationDestination(item: Binding<String?>(
get: { selectedBookmarkId },
set: { selectedBookmarkId = $0 }
)
) { bookmarkId in
BookmarkDetailView(bookmarkId: bookmarkId)
}
.sheet(isPresented: $showingAddBookmark) {
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
}
.sheet(
isPresented: $viewModel.showingAddBookmarkFromShare,
content: {
)) { bookmarkId in
BookmarkDetailView(bookmarkId: bookmarkId)
}
.sheet(isPresented: $showingAddBookmark) {
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
}
)
/*.alert("Fehler", isPresented: .constant(viewModel.errorMessage != nil)) {
Button("OK", role: .cancel) {
viewModel.errorMessage = nil
}
} message: {
Text(viewModel.errorMessage ?? "")
}*/
.onAppear {
Task {
await viewModel.loadBookmarks(state: state)
}
}
.onChange(of: showingAddBookmark) { oldValue, newValue in
// Refresh bookmarks when sheet is dismissed
if oldValue && !newValue {
.sheet(isPresented: $viewModel.showingAddBookmarkFromShare, content: {
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
})
/*.alert("Fehler", isPresented: .constant(viewModel.errorMessage != nil)) {
Button("OK", role: .cancel) {
viewModel.errorMessage = nil
}
} message: {
Text(viewModel.errorMessage ?? "")
}*/
.onAppear {
Task {
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,21 +8,21 @@ class BookmarksViewModel {
private let updateBookmarkUseCase = DefaultUseCaseFactory.shared.makeUpdateBookmarkUseCase()
private let deleteBookmarkUseCase = DefaultUseCaseFactory.shared.makeDeleteBookmarkUseCase()
var bookmarks: BookmarksPage?
var bookmarks: [Bookmark] = []
var isLoading = false
var errorMessage: String?
var currentState: BookmarkState = .unread
var type = [BookmarkType.article]
var showingAddBookmarkFromShare = false
var shareURL = ""
var shareTitle = ""
private var cancellables = Set<AnyCancellable>()
// Pagination-Variablen
private var limit = 20
private var offset = 0
private var hasMoreData = true
private var searchWorkItem: DispatchWorkItem?
var searchQuery: String = "" {
didSet {
@ -30,6 +30,8 @@ class BookmarksViewModel {
}
}
private var searchWorkItem: DispatchWorkItem?
init() {
setupNotificationObserver()
}
@ -86,14 +88,13 @@ class BookmarksViewModel {
state: state,
limit: limit,
offset: offset,
search: searchQuery,
type: type
search: searchQuery // Suche integrieren
)
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 {
errorMessage = "Fehler beim Laden der Bookmarks"
bookmarks = nil
bookmarks = []
}
isLoading = false
@ -109,8 +110,8 @@ class BookmarksViewModel {
do {
offset += limit // Offset erhöhen
let newBookmarks = try await getBooksmarksUseCase.execute(state: currentState, limit: limit, offset: offset)
bookmarks?.bookmarks.append(contentsOf: newBookmarks.bookmarks) // Neue Bookmarks hinzufügen
hasMoreData = newBookmarks.bookmarks.count == limit // Prüfen,
bookmarks.append(contentsOf: newBookmarks) // Neue Bookmarks hinzufügen
hasMoreData = newBookmarks.count == limit // Prüfen, ob weitere Daten verfügbar sind
} catch {
errorMessage = "Fehler beim Nachladen der Bookmarks"
}
@ -162,7 +163,7 @@ class BookmarksViewModel {
try await deleteBookmarkUseCase.execute(bookmarkId: bookmark.id)
// Lokal aus der Liste entfernen (optimistische Update)
bookmarks?.bookmarks.removeAll { $0.id == bookmark.id }
bookmarks.removeAll { $0.id == bookmark.id }
} catch {
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
struct SettingsContainerView: View {
@State private var viewModel = SettingsViewModel()
var body: some View {
ScrollView {
LazyVStack(spacing: 20) {
SettingsServerView()
.cardStyle()
NavigationView {
ScrollView {
LazyVStack(spacing: 20) {
// Server-Card immer anzeigen
SettingsServerView(viewModel: viewModel)
.cardStyle()
FontSettingsView()
.cardStyle()
SettingsGeneralView()
.cardStyle()
// Allgemeine Einstellungen nur im normalen Modus anzeigen
if !viewModel.isSetupMode {
SettingsGeneralView(viewModel: viewModel)
.cardStyle()
}
}
.padding()
.background(Color(.systemGroupedBackground))
}
.padding()
.background(Color(.systemGroupedBackground))
.navigationTitle("Einstellungen")
.navigationBarTitleDisplayMode(.large)
}
.task {
await viewModel.loadSettings()
}
.navigationTitle("Einstellungen")
.navigationBarTitleDisplayMode(.large)
}
}

View File

@ -9,7 +9,7 @@ import SwiftUI
// SectionHeader wird jetzt zentral importiert
struct SettingsGeneralView: View {
@State private var viewModel = SettingsGeneralViewModel()
@State var viewModel: SettingsViewModel
var body: some View {
VStack(spacing: 20) {
@ -28,6 +28,10 @@ struct SettingsGeneralView: View {
.pickerStyle(.segmented)
}
// Font Settings
FontSettingsView()
.padding(.vertical, 4)
// Sync Settings
VStack(alignment: .leading, spacing: 12) {
Text("Sync-Einstellungen")
@ -114,19 +118,26 @@ struct SettingsGeneralView: View {
// Save Button
Button(action: {
Task {
await viewModel.saveGeneralSettings()
await viewModel.saveSettings()
}
}) {
HStack {
Text("Einstellungen speichern")
if viewModel.isSaving {
ProgressView()
.scaleEffect(0.8)
.progressViewStyle(CircularProgressViewStyle(tint: .white))
}
Text(viewModel.isSaving ? "Speichere..." : "Einstellungen speichern")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding()
.background(Color.accentColor)
.background(viewModel.canSave ? Color.accentColor : Color.gray)
.foregroundColor(.white)
.cornerRadius(10)
}
.disabled(!viewModel.canSave || viewModel.isSaving)
// Messages
if let successMessage = viewModel.successMessage {
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 {
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
struct SettingsServerView: View {
@State private var viewModel = SettingsServerViewModel()
@State var viewModel = SettingsViewModel()
@State private var isTesting: Bool = false
@State private var connectionTestSuccess: Bool = false
@State private var showingLogoutAlert = false
@ -188,18 +188,38 @@ struct SettingsServerView: View {
} message: {
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 {
guard viewModel.canLogin else {
viewModel.errorMessage = "Bitte füllen Sie alle Felder aus."
return
}
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
}
}
#Preview {
SettingsServerView()
SettingsServerView(viewModel: SettingsViewModel())
}

View File

@ -3,11 +3,11 @@ import Observation
import SwiftUI
@Observable
class SettingsServerViewModel {
private let loginUseCase: LoginUseCase
private let logoutUseCase: LogoutUseCase
class SettingsViewModel {
private let _loginUseCase: LoginUseCase
private let saveSettingsUseCase: SaveSettingsUseCase
private let loadSettingsUseCase: LoadSettingsUseCase
private let logoutUseCase: LogoutUseCase
private let settingsRepository: SettingsRepository
// MARK: - Server Settings
@ -15,17 +15,36 @@ class SettingsServerViewModel {
var username = ""
var password = ""
var isLoading = false
var isSaving = 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
var errorMessage: String?
var successMessage: String?
var errorMessage: String?
var successMessage: String?
init() {
let factory = DefaultUseCaseFactory.shared
self.loginUseCase = factory.makeLoginUseCase()
self.logoutUseCase = factory.makeLogoutUseCase()
self._loginUseCase = factory.makeLoginUseCase()
self.saveSettingsUseCase = factory.makeSaveSettingsUseCase()
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
self.logoutUseCase = factory.makeLogoutUseCase()
self.settingsRepository = SettingsRepository()
}
@ -34,7 +53,7 @@ class SettingsServerViewModel {
}
@MainActor
func loadServerSettings() async {
func loadSettings() async {
do {
if let settings = try await loadSettingsUseCase.execute() {
endpoint = settings.endpoint ?? ""
@ -48,45 +67,27 @@ class SettingsServerViewModel {
}
@MainActor
func saveServerSettings() async {
func saveSettings() async {
isSaving = true
errorMessage = nil
successMessage = nil
do {
try await saveSettingsUseCase.execute(
endpoint: endpoint,
username: username,
password: password
)
successMessage = "Server-Einstellungen gespeichert"
} catch {
errorMessage = "Fehler beim Speichern der Server-Einstellungen"
}
}
successMessage = "Einstellungen gespeichert"
@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
)
successMessage = "Verbindung erfolgreich getestet! ✓"
return true
// Factory-Konfiguration aktualisieren
await DefaultUseCaseFactory.shared.refreshConfiguration()
} catch {
errorMessage = "Verbindungstest fehlgeschlagen: \(error.localizedDescription)"
errorMessage = "Fehler beim Speichern der Einstellungen"
}
return false
isSaving = false
}
@MainActor
@ -94,17 +95,27 @@ class SettingsServerViewModel {
isLoading = true
errorMessage = nil
successMessage = nil
do {
let _ = try await loginUseCase.execute(username: username, password: password)
let user = try await _loginUseCase.execute(username: username, password: password)
isLoggedIn = true
successMessage = "Erfolgreich angemeldet"
// Setup als abgeschlossen markieren
try await settingsRepository.saveHasFinishedSetup(true)
// Notification senden, dass sich der Setup-Status geändert hat
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
// Factory-Konfiguration aktualisieren (Token wird automatisch gespeichert)
await DefaultUseCaseFactory.shared.refreshConfiguration()
} catch {
errorMessage = "Anmeldung fehlgeschlagen"
isLoggedIn = false
}
isLoading = false
}
@ -114,7 +125,10 @@ class SettingsServerViewModel {
try await logoutUseCase.execute()
isLoggedIn = false
successMessage = "Abgemeldet"
// Notification senden, dass sich der Setup-Status geändert hat
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
} catch {
errorMessage = "Fehler beim Abmelden"
}
@ -128,7 +142,28 @@ class SettingsServerViewModel {
var canSave: Bool {
!endpoint.isEmpty && !username.isEmpty && !password.isEmpty
}
var canLogin: Bool {
!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()
.environment(\.managedObjectContext, persistenceController.container.viewContext)
} else {
SettingsServerView()
.padding()
SettingsContainerView()
}
}
.onOpenURL { url in