Compare commits

...

2 Commits

Author SHA1 Message Date
be68538da3 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
2025-07-02 16:26:07 +02:00
7df56687c7 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
2025-07-02 16:25:23 +02:00
25 changed files with 665 additions and 445 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
@ -37,6 +37,46 @@ 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 // Separate Methode für JSON-Requests
private func makeJSONRequest<T: Codable>( private func makeJSONRequest<T: Codable>(
endpoint: String, endpoint: String,
@ -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,14 @@
//
// 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,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 {

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,36 +1,40 @@
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.isEmpty { if viewModel.isLoading && viewModel.bookmarks?.bookmarks.isEmpty == true {
ProgressView("Lade \(state.displayName)...") ProgressView("Lade \(state.displayName)...")
} else { } else {
List { List {
ForEach(viewModel.bookmarks, id: \.id) { bookmark in ForEach(viewModel.bookmarks?.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 {
// Optional: Deselect, um erneutes Auswählen zu ermöglichen
selectedBookmark = nil selectedBookmark = nil
DispatchQueue.main.async { DispatchQueue.main.async {
selectedBookmark = bookmark selectedBookmark = bookmark
@ -60,7 +64,7 @@ struct BookmarksView: View {
} }
) )
.onAppear { .onAppear {
if bookmark.id == viewModel.bookmarks.last?.id { if bookmark.id == viewModel.bookmarks?.bookmarks.last?.id {
Task { Task {
await viewModel.loadMoreBookmarks() await viewModel.loadMoreBookmarks()
} }
@ -78,15 +82,18 @@ struct BookmarksView: View {
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 // FAB Button - nur bei "Ungelesen" anzeigen
@ -115,18 +122,23 @@ struct BookmarksView: View {
} }
} }
.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) AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
} }
.sheet(isPresented: $viewModel.showingAddBookmarkFromShare, content: { .sheet(
isPresented: $viewModel.showingAddBookmarkFromShare,
content: {
AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle) AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle)
}) }
)
/*.alert("Fehler", isPresented: .constant(viewModel.errorMessage != nil)) { /*.alert("Fehler", isPresented: .constant(viewModel.errorMessage != nil)) {
Button("OK", role: .cancel) { Button("OK", role: .cancel) {
viewModel.errorMessage = nil viewModel.errorMessage = nil
@ -148,8 +160,6 @@ struct BookmarksView: View {
} }
} }
} }
.searchable(text: $viewModel.searchQuery, placement: .automatic, prompt: "Search...")
}
} }
// String Identifiable Extension für navigationDestination // String Identifiable Extension für navigationDestination

View File

@ -8,21 +8,21 @@ 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 {
@ -30,8 +30,6 @@ class BookmarksViewModel {
} }
} }
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

@ -0,0 +1,39 @@
//
// 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

@ -0,0 +1,87 @@
//
// 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

@ -0,0 +1,45 @@
//
// 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

@ -0,0 +1,40 @@
//
// 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

@ -0,0 +1,26 @@
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,21 +8,17 @@
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) {
// Server-Card immer anzeigen SettingsServerView()
SettingsServerView(viewModel: viewModel)
.cardStyle() .cardStyle()
// Allgemeine Einstellungen nur im normalen Modus anzeigen FontSettingsView()
if !viewModel.isSetupMode { .cardStyle()
SettingsGeneralView(viewModel: viewModel)
SettingsGeneralView()
.cardStyle() .cardStyle()
}
} }
.padding() .padding()
.background(Color(.systemGroupedBackground)) .background(Color(.systemGroupedBackground))
@ -30,10 +26,6 @@ struct SettingsContainerView: View {
.navigationTitle("Einstellungen") .navigationTitle("Einstellungen")
.navigationBarTitleDisplayMode(.large) .navigationBarTitleDisplayMode(.large)
} }
.task {
await viewModel.loadSettings()
}
}
} }
// Card Modifier für einheitlichen Look // Card Modifier für einheitlichen Look

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()
}
} }
} }
#Preview { enum Theme: String, CaseIterable {
SettingsGeneralView(viewModel: SettingsViewModel()) 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()
} }

View File

@ -0,0 +1,77 @@
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 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

@ -3,11 +3,11 @@ import Observation
import SwiftUI import SwiftUI
@Observable @Observable
class SettingsViewModel { class SettingsServerViewModel {
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,36 +15,17 @@ class SettingsViewModel {
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()
} }
@ -53,7 +34,7 @@ class SettingsViewModel {
} }
@MainActor @MainActor
func loadSettings() async { func loadServerSettings() async {
do { do {
if let settings = try await loadSettingsUseCase.execute() { if let settings = try await loadSettingsUseCase.execute() {
endpoint = settings.endpoint ?? "" endpoint = settings.endpoint ?? ""
@ -67,27 +48,45 @@ class SettingsViewModel {
} }
@MainActor @MainActor
func saveSettings() async { func saveServerSettings() 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 = "Einstellungen gespeichert" successMessage = "Server-Einstellungen gespeichert"
// Factory-Konfiguration aktualisieren
await DefaultUseCaseFactory.shared.refreshConfiguration()
} catch { } catch {
errorMessage = "Fehler beim Speichern der Einstellungen" errorMessage = "Fehler beim Speichern der Server-Einstellungen"
}
} }
isSaving = false @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
@ -95,27 +94,17 @@ class SettingsViewModel {
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
successMessage = nil successMessage = nil
do { do {
let user = try await _loginUseCase.execute(username: username, password: password) let _ = 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
} }
@ -125,10 +114,7 @@ class SettingsViewModel {
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"
} }
@ -142,28 +128,7 @@ class SettingsViewModel {
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"
}
}
} }

View File

@ -1,144 +0,0 @@
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,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