Refactor UI navigation and settings management

- Split TabView and Sidebar logic into PhoneTabView, PadSidebarView, SidebarTab, and BookmarkState for better device adaptation
- Remove old SettingsViewModel, introduce SettingsGeneralViewModel and SettingsServerViewModel for modular settings
- Update BookmarksView and BookmarksViewModel for new paginated and filtered data model
- Clean up and modularize settings UI (SettingsGeneralView, SettingsServerView, FontSettingsView)
- Remove obsolete files (old TabView, File.swift, SettingsViewModel, etc.)
- Add BookmarksPageDto and update related data flow
- Various UI/UX improvements and code cleanup

BREAKING: Settings and navigation structure refactored, old settings logic removed
This commit is contained in:
Ilyas Hallak 2025-07-02 16:26:07 +02:00
parent 7df56687c7
commit be68538da3
24 changed files with 420 additions and 600 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?) async throws -> [BookmarkDto] func getBookmarks(state: BookmarkState?, limit: Int?, offset: Int?, search: String?, type: [BookmarkType]?) async throws -> BookmarksPageDto
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,6 +36,46 @@ 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>(
@ -131,7 +171,8 @@ class API: PAPI {
return userDto return userDto
} }
func getBookmarks(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil, search: String? = nil) async throws -> [BookmarkDto] { // 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 {
var endpoint = "/api/bookmarks" var endpoint = "/api/bookmarks"
var queryItems: [URLQueryItem] = [] var queryItems: [URLQueryItem] = []
@ -145,6 +186,8 @@ 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
} }
} }
@ -159,15 +202,37 @@ 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)"
} }
return try await makeJSONRequest( let (bookmarks, response) = try await makeJSONRequestWithHeaders(
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

@ -0,0 +1,6 @@
import Foundation
struct CreateBookmarkResponseDto: Codable {
let message: String
let status: Int
}

View File

@ -0,0 +1,11 @@
import Foundation
struct UserDto: Codable {
let id: String
let token: String
enum CodingKeys: String, CodingKey {
case id
case token
}
}

View File

@ -1,5 +1,17 @@
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 {
@ -58,4 +70,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?) async throws -> [Bookmark] func fetchBookmarks(state: BookmarkState?, limit: Int?, offset: Int?, search: String?, type: [BookmarkType]?) async throws -> BookmarksPage
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) async throws -> [Bookmark] { 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) let bookmarkDtos = try await api.getBookmarks(state: state, limit: limit, offset: offset, search: search, type: type)
return bookmarkDtos.map { $0.toDomain() } return bookmarkDtos.toDomain()
} }
func fetchBookmark(id: String) async throws -> BookmarkDetail { func fetchBookmark(id: String) async throws -> BookmarkDetail {

View File

@ -1,5 +1,13 @@
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

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

View File

@ -7,13 +7,14 @@ class GetBookmarksUseCase {
self.repository = repository self.repository = repository
} }
func execute(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil, search: String? = nil) async throws -> [Bookmark] { func execute(state: BookmarkState? = nil, limit: Int? = nil, offset: Int? = nil, search: String? = nil, type: [BookmarkType]? = nil) async throws -> BookmarksPage {
let allBookmarks = try await repository.fetchBookmarks(state: state, limit: limit, offset: offset, search: search) var allBookmarks = try await repository.fetchBookmarks(state: state, limit: limit, offset: offset, search: search, type: type)
// Fallback-Filterung auf Client-Seite falls API keine Query-Parameter unterstützt
if let state = state { if let state = state {
return allBookmarks.filter { bookmark in allBookmarks.bookmarks = allBookmarks.bookmarks.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,22 +82,11 @@ 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)
@ -111,6 +100,14 @@ 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)
} }
} }
@ -121,7 +118,6 @@ 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,154 +1,164 @@
import Foundation
import Combine import Combine
import Foundation
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 {
NavigationStack { ZStack {
ZStack { if viewModel.isLoading && viewModel.bookmarks?.bookmarks.isEmpty == true {
if viewModel.isLoading && viewModel.bookmarks.isEmpty { ProgressView("Lade \(state.displayName)...")
ProgressView("Lade \(state.displayName)...") } else {
} else { List {
List { ForEach(viewModel.bookmarks?.bookmarks ?? [], id: \.id) { bookmark in
ForEach(viewModel.bookmarks, id: \.id) { bookmark in Button(action: {
Button(action: { if UIDevice.isPhone {
if UIDevice.isPhone { selectedBookmarkId = bookmark.id
selectedBookmarkId = bookmark.id } else {
} else { if selectedBookmark?.id == bookmark.id {
if selectedBookmark?.id == bookmark.id { selectedBookmark = nil
// Optional: Deselect, um erneutes Auswählen zu ermöglichen DispatchQueue.main.async {
selectedBookmark = nil
DispatchQueue.main.async {
selectedBookmark = bookmark
}
} else {
selectedBookmark = bookmark selectedBookmark = bookmark
} }
} } else {
}) { selectedBookmark = 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)
}
}
)
.onAppear {
if bookmark.id == viewModel.bookmarks.last?.id {
Task {
await viewModel.loadMoreBookmarks()
}
}
} }
} }
.buttonStyle(PlainButtonStyle()) }) {
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) BookmarkCardView(
.listRowSeparator(.hidden) bookmark: bookmark,
.listRowBackground(Color.clear) currentState: state,
} onArchive: { bookmark in
} Task {
.listStyle(.plain) await viewModel.toggleArchive(bookmark: bookmark)
.refreshable { }
await viewModel.refreshBookmarks() },
} onDelete: { bookmark in
.overlay { Task {
if viewModel.bookmarks.isEmpty && !viewModel.isLoading { await viewModel.deleteBookmark(bookmark: bookmark)
ContentUnavailableView( }
"Keine Bookmarks", },
systemImage: "bookmark", onToggleFavorite: { bookmark in
description: Text("Es wurden noch keine Bookmarks in \(state.displayName.lowercased()) gefunden.") Task {
await viewModel.toggleFavorite(bookmark: bookmark)
}
}
) )
} .onAppear {
} if bookmark.id == viewModel.bookmarks?.bookmarks.last?.id {
Task {
} await viewModel.loadMoreBookmarks()
}
// 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)
} }
.padding(.trailing, 20)
.padding(.bottom, 20)
} }
.buttonStyle(PlainButtonStyle())
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
} }
} }
} .listStyle(.plain)
.navigationTitle(state.displayName) .refreshable {
.navigationDestination(item: Binding<String?>( await viewModel.refreshBookmarks()
get: { selectedBookmarkId },
set: { selectedBookmarkId = $0 }
)) { bookmarkId in
BookmarkDetailView(bookmarkId: bookmarkId)
}
.sheet(isPresented: $showingAddBookmark) {
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
}
.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: { .overlay {
Text(viewModel.errorMessage ?? "") if viewModel.bookmarks?.bookmarks.isEmpty == true && !viewModel.isLoading {
}*/ ContentUnavailableView(
.onAppear { "Keine Bookmarks",
Task { systemImage: "bookmark",
await viewModel.loadBookmarks(state: state) description: Text(
"Es wurden noch keine Bookmarks in \(state.displayName.lowercased()) gefunden."
)
)
}
} }
.searchable(
text: $viewModel.searchQuery, placement: .automatic, prompt: "Search...")
} }
.onChange(of: showingAddBookmark) { oldValue, newValue in
// Refresh bookmarks when sheet is dismissed // FAB Button - nur bei "Ungelesen" anzeigen
if oldValue && !newValue { if state == .unread {
Task { VStack {
await viewModel.loadBookmarks(state: state) 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)
}
.padding(.trailing, 20)
.padding(.bottom, 20)
} }
} }
} }
} }
.searchable(text: $viewModel.searchQuery, placement: .automatic, prompt: "Search...") .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: {
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)
}
}
}
} }
} }

View File

@ -8,29 +8,27 @@ 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: [Bookmark] = [] var bookmarks: BookmarksPage?
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()
@ -88,13 +86,14 @@ class BookmarksViewModel {
state: state, state: state,
limit: limit, limit: limit,
offset: offset, offset: offset,
search: searchQuery // Suche integrieren search: searchQuery,
type: type
) )
bookmarks = newBookmarks bookmarks = newBookmarks
hasMoreData = newBookmarks.count == limit // Prüfen, ob weitere Daten verfügbar sind hasMoreData = newBookmarks.bookmarks.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 = [] bookmarks = nil
} }
isLoading = false isLoading = false
@ -110,8 +109,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.append(contentsOf: newBookmarks) // Neue Bookmarks hinzufügen bookmarks?.bookmarks.append(contentsOf: newBookmarks.bookmarks) // Neue Bookmarks hinzufügen
hasMoreData = newBookmarks.count == limit // Prüfen, ob weitere Daten verfügbar sind hasMoreData = newBookmarks.bookmarks.count == limit // Prüfen,
} catch { } catch {
errorMessage = "Fehler beim Nachladen der Bookmarks" errorMessage = "Fehler beim Nachladen der Bookmarks"
} }
@ -163,7 +162,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.removeAll { $0.id == bookmark.id } bookmarks?.bookmarks.removeAll { $0.id == bookmark.id }
} catch { } catch {
errorMessage = "Fehler beim Löschen des Bookmarks" errorMessage = "Fehler beim Löschen des Bookmarks"

View File

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

View File

@ -1,10 +1,20 @@
//
// BookmarkState.swift
// readeck
//
// Created by Ilyas Hallak on 01.07.25.
//
enum BookmarkState: String, CaseIterable { enum BookmarkState: String, CaseIterable {
case all = "all"
case unread = "unread" case unread = "unread"
case favorite = "favorite" case favorite = "favorite"
case archived = "archived" case archived = "archived"
var displayName: String { var displayName: String {
switch self { switch self {
case .all:
return "Alle"
case .unread: case .unread:
return "Ungelesen" return "Ungelesen"
case .favorite: case .favorite:
@ -16,6 +26,8 @@ enum BookmarkState: String, CaseIterable {
var systemImage: String { var systemImage: String {
switch self { switch self {
case .all:
return "list.bullet"
case .unread: case .unread:
return "house" return "house"
case .favorite: case .favorite:
@ -24,4 +36,4 @@ enum BookmarkState: String, CaseIterable {
return "archivebox" return "archivebox"
} }
} }
} }

View File

@ -1,3 +1,12 @@
//
// PadSidebarView.swift
// readeck
//
// Created by Ilyas Hallak on 01.07.25.
//
import SwiftUI
struct PadSidebarView: View { struct PadSidebarView: View {
@State private var selectedTab: SidebarTab = .unread @State private var selectedTab: SidebarTab = .unread
@State private var selectedBookmark: Bookmark? @State private var selectedBookmark: Bookmark?
@ -14,12 +23,14 @@ struct PadSidebarView: View {
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle()) .contentShape(Rectangle())
if tab == .article { if tab == .archived {
Spacer() Spacer(minLength: 20)
} }
if tab == .pictures { if tab == .pictures {
Spacer(minLength: 30)
Divider() Divider()
Spacer()
} }
} }
.listRowBackground(selectedTab == tab ? Color.accentColor.opacity(0.15) : Color.clear) .listRowBackground(selectedTab == tab ? Color.accentColor.opacity(0.15) : Color.clear)
@ -46,21 +57,21 @@ struct PadSidebarView: View {
} content: { } content: {
switch selectedTab { switch selectedTab {
case .all: case .all:
Text("All") BookmarksView(state: .all, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark)
case .unread: case .unread:
BookmarksView(state: .unread, selectedBookmark: $selectedBookmark) BookmarksView(state: .unread, type: [.article], selectedBookmark: $selectedBookmark)
case .favorite: case .favorite:
BookmarksView(state: .favorite, selectedBookmark: $selectedBookmark) BookmarksView(state: .favorite, type: [.article], selectedBookmark: $selectedBookmark)
case .archived: case .archived:
BookmarksView(state: .archived, selectedBookmark: $selectedBookmark) BookmarksView(state: .archived, type: [.article], selectedBookmark: $selectedBookmark)
case .settings: case .settings:
SettingsView() SettingsView()
case .article: case .article:
Text("Artikel") BookmarksView(state: .all, type: [.article], selectedBookmark: $selectedBookmark)
case .videos: case .videos:
Text("Videos") BookmarksView(state: .all, type: [.video], selectedBookmark: $selectedBookmark)
case .pictures: case .pictures:
Text("Pictures") BookmarksView(state: .all, type: [.photo], selectedBookmark: $selectedBookmark)
case .tags: case .tags:
Text("Tags") Text("Tags")
} }
@ -73,4 +84,4 @@ struct PadSidebarView: View {
} }
} }
} }
} }

View File

@ -1,19 +1,36 @@
//
// PhoneTabView.swift
// readeck
//
// Created by Ilyas Hallak on 01.07.25.
//
import SwiftUI
struct PhoneTabView: View { struct PhoneTabView: View {
var body: some View { var body: some View {
TabView { TabView {
NavigationStack { NavigationStack {
BookmarksView(state: .unread, selectedBookmark: .constant(nil)) 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 { .tabItem {
Label("Ungelesen", systemImage: "house") Label("Ungelesen", systemImage: "house")
} }
BookmarksView(state: .favorite, selectedBookmark: .constant(nil)) BookmarksView(state: .favorite, type: [.article], selectedBookmark: .constant(nil))
.tabItem { .tabItem {
Label("Favoriten", systemImage: "heart") Label("Favoriten", systemImage: "heart")
} }
BookmarksView(state: .archived, selectedBookmark: .constant(nil)) BookmarksView(state: .archived, type: [.article], selectedBookmark: .constant(nil))
.tabItem { .tabItem {
Label("Archiv", systemImage: "archivebox") Label("Archiv", systemImage: "archivebox")
} }

View File

@ -1,3 +1,10 @@
//
// SidebarTab.swift
// readeck
//
// Created by Ilyas Hallak on 01.07.25.
//
enum SidebarTab: Hashable, CaseIterable, Identifiable { enum SidebarTab: Hashable, CaseIterable, Identifiable {
case all, unread, favorite, archived, settings, article, videos, pictures, tags case all, unread, favorite, archived, settings, article, videos, pictures, tags
@ -30,4 +37,4 @@ enum SidebarTab: Hashable, CaseIterable, Identifiable {
case .tags: return "tag" case .tags: return "tag"
} }
} }
} }

View File

@ -1,34 +1,6 @@
import SwiftUI import SwiftUI
import Foundation 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 { struct MainTabView: View {
@State private var selectedTab: SidebarTab = .unread @State private var selectedTab: SidebarTab = .unread
@State var selectedBookmark: Bookmark? @State var selectedBookmark: Bookmark?
@ -42,162 +14,13 @@ struct MainTabView: View {
var body: some View { var body: some View {
if UIDevice.isPhone { if UIDevice.isPhone {
PhoneView() PhoneTabView()
} else { } else {
PadSidebarView() PadSidebarView()
} }
} }
} }
// Sidebar Tabs
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"
}
}
}
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 == .article {
Spacer()
}
if tab == .pictures {
Divider()
}
}
.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:
Text("All")
case .unread:
BookmarksView(state: .unread, selectedBookmark: $selectedBookmark)
case .favorite:
BookmarksView(state: .favorite, selectedBookmark: $selectedBookmark)
case .archived:
BookmarksView(state: .archived, selectedBookmark: $selectedBookmark)
case .settings:
SettingsView()
case .article:
Text("Artikel")
case .videos:
Text("Videos")
case .pictures:
Text("Pictures")
case .tags:
Text("Tags")
}
} detail: {
if let bookmark = selectedBookmark, selectedTab != .settings {
BookmarkDetailView(bookmarkId: bookmark.id)
} else {
Text(selectedTab == .settings ? "" : "Select a bookmark")
.foregroundColor(.gray)
}
}
}
}
// iPhone: TabView bleibt wie gehabt
extension MainTabView {
@ViewBuilder
fileprivate func PhoneView() -> some View {
TabView {
NavigationStack {
BookmarksView(state: .unread, selectedBookmark: .constant(nil))
}
.tabItem {
Label("Ungelesen", systemImage: "house")
}
NavigationView {
BookmarksView(state: .favorite, selectedBookmark: .constant(nil))
.tabItem {
Label("Favoriten", systemImage: "heart")
}
}
NavigationView {
BookmarksView(state: .archived, selectedBookmark: .constant(nil))
.tabItem {
Label("Archiv", systemImage: "archivebox")
}
}
NavigationView {
SettingsView()
.tabItem {
Label("Settings", systemImage: "gear")
}
}
}
.accentColor(.accentColor)
}
}
#Preview { #Preview {
MainTabView() MainTabView()
} }

View File

@ -8,31 +8,23 @@
import SwiftUI import SwiftUI
struct SettingsContainerView: View { struct SettingsContainerView: View {
@State private var viewModel = SettingsViewModel()
var body: some View { var body: some View {
NavigationView { ScrollView {
ScrollView { LazyVStack(spacing: 20) {
LazyVStack(spacing: 20) { SettingsServerView()
// Server-Card immer anzeigen .cardStyle()
SettingsServerView(viewModel: viewModel)
.cardStyle() FontSettingsView()
.cardStyle()
// Allgemeine Einstellungen nur im normalen Modus anzeigen
if !viewModel.isSetupMode { SettingsGeneralView()
SettingsGeneralView(viewModel: viewModel) .cardStyle()
.cardStyle()
}
}
.padding()
.background(Color(.systemGroupedBackground))
} }
.navigationTitle("Einstellungen") .padding()
.navigationBarTitleDisplayMode(.large) .background(Color(.systemGroupedBackground))
}
.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 var viewModel: SettingsViewModel @State private var viewModel = SettingsGeneralViewModel()
var body: some View { var body: some View {
VStack(spacing: 20) { VStack(spacing: 20) {
@ -28,10 +28,6 @@ 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")
@ -118,26 +114,19 @@ struct SettingsGeneralView: View {
// Save Button // Save Button
Button(action: { Button(action: {
Task { Task {
await viewModel.saveSettings() await viewModel.saveGeneralSettings()
} }
}) { }) {
HStack { HStack {
if viewModel.isSaving { Text("Einstellungen speichern")
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(viewModel.canSave ? Color.accentColor : Color.gray) .background(Color.accentColor)
.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 {
@ -158,9 +147,27 @@ 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(viewModel: SettingsViewModel()) SettingsGeneralView()
} }

View File

@ -37,13 +37,13 @@ class SettingsGeneralViewModel {
do { do {
if let settings = try await loadSettingsUseCase.execute() { if let settings = try await loadSettingsUseCase.execute() {
selectedTheme = .system // settings.theme ?? .system selectedTheme = .system // settings.theme ?? .system
autoSyncEnabled = settings.autoSyncEnabled autoSyncEnabled = false // settings.autoSyncEnabled
syncInterval = settings.syncInterval // syncInterval = settings.syncInterval
enableReaderMode = settings.enableReaderMode // enableReaderMode = settings.enableReaderMode
openExternalLinksInApp = settings.openExternalLinksInApp // openExternalLinksInApp = settings.openExternalLinksInApp
autoMarkAsRead = settings.autoMarkAsRead // autoMarkAsRead = settings.autoMarkAsRead
appVersion = settings.appVersion ?? "1.0.0" appVersion = "1.0.0"
developerName = settings.developerName ?? "Your Name" developerName = "Ilyas Hallak"
} }
} catch { } catch {
errorMessage = "Fehler beim Laden der Einstellungen" errorMessage = "Fehler beim Laden der Einstellungen"
@ -53,14 +53,17 @@ class SettingsGeneralViewModel {
@MainActor @MainActor
func saveGeneralSettings() async { func saveGeneralSettings() async {
do { do {
try await saveSettingsUseCase.execute(
// TODO: add save general settings here
/*try await saveSettingsUseCase.execute(
token: "",
selectedTheme: selectedTheme, selectedTheme: selectedTheme,
autoSyncEnabled: autoSyncEnabled, autoSyncEnabled: autoSyncEnabled,
syncInterval: syncInterval, syncInterval: syncInterval,
enableReaderMode: enableReaderMode, enableReaderMode: enableReaderMode,
openExternalLinksInApp: openExternalLinksInApp, openExternalLinksInApp: openExternalLinksInApp,
autoMarkAsRead: autoMarkAsRead autoMarkAsRead: autoMarkAsRead
) )*/
successMessage = "Einstellungen gespeichert" successMessage = "Einstellungen gespeichert"
} catch { } catch {
errorMessage = "Fehler beim Speichern der Einstellungen" errorMessage = "Fehler beim Speichern der Einstellungen"

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 var viewModel = SettingsViewModel() @State private var viewModel = SettingsServerViewModel()
@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,38 +188,18 @@ 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
viewModel.clearMessages() connectionTestSuccess = await viewModel.testConnection()
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(viewModel: SettingsViewModel()) SettingsServerView()
} }

View File

@ -61,6 +61,34 @@ class SettingsServerViewModel {
} }
} }
@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
} catch {
errorMessage = "Verbindungstest fehlgeschlagen: \(error.localizedDescription)"
}
return false
}
@MainActor @MainActor
func login() async { func login() async {
isLoading = true isLoading = true
@ -103,4 +131,4 @@ class SettingsServerViewModel {
var canLogin: Bool { var canLogin: Bool {
!username.isEmpty && !password.isEmpty !username.isEmpty && !password.isEmpty
} }
} }

View File

@ -1,169 +0,0 @@
import Foundation
import Observation
import SwiftUI
@Observable
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
var endpoint = ""
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?
init() {
let factory = DefaultUseCaseFactory.shared
self._loginUseCase = factory.makeLoginUseCase()
self.saveSettingsUseCase = factory.makeSaveSettingsUseCase()
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
self.logoutUseCase = factory.makeLogoutUseCase()
self.settingsRepository = SettingsRepository()
}
var isSetupMode: Bool {
!settingsRepository.hasFinishedSetup
}
@MainActor
func loadSettings() async {
do {
if let settings = try await loadSettingsUseCase.execute() {
endpoint = settings.endpoint ?? ""
username = settings.username ?? ""
password = settings.password ?? ""
isLoggedIn = settings.isLoggedIn
}
} catch {
errorMessage = "Fehler beim Laden der Einstellungen"
}
}
@MainActor
func saveSettings() async {
isSaving = true
errorMessage = nil
successMessage = nil
do {
try await saveSettingsUseCase.execute(
endpoint: endpoint,
username: username,
password: password
)
successMessage = "Einstellungen gespeichert"
// Factory-Konfiguration aktualisieren
await DefaultUseCaseFactory.shared.refreshConfiguration()
} catch {
errorMessage = "Fehler beim Speichern der Einstellungen"
}
isSaving = false
}
@MainActor
func login() async {
isLoading = true
errorMessage = nil
successMessage = nil
do {
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
}
@MainActor
func logout() async {
do {
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"
}
}
func clearMessages() {
errorMessage = nil
successMessage = nil
}
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"
}
}
}

View File

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