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 {
var tokenProvider: TokenProvider { get }
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 getBookmarkArticle(id: String) async throws -> String
func createBookmark(createRequest: CreateBookmarkRequestDto) async throws -> CreateBookmarkResponseDto
@ -36,6 +36,46 @@ class API: PAPI {
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
private func makeJSONRequest<T: Codable>(
@ -131,7 +171,8 @@ class API: PAPI {
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 queryItems: [URLQueryItem] = []
@ -145,6 +186,8 @@ class API: PAPI {
queryItems.append(URLQueryItem(name: "is_marked", value: "true"))
case .archived:
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))
}
// 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)"
}
return try await makeJSONRequest(
let (bookmarks, response) = try await makeJSONRequestWithHeaders(
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

@ -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
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 {
@ -58,4 +70,4 @@ extension ImageResourceDto {
func toDomain() -> ImageResource {
return ImageResource(src: src, height: height, width: width)
}
}
}

View File

@ -1,7 +1,7 @@
import Foundation
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 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) async throws -> [Bookmark] {
let bookmarkDtos = try await api.getBookmarks(state: state, limit: limit, offset: offset, search: search)
return bookmarkDtos.map { $0.toDomain() }
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 fetchBookmark(id: String) async throws -> BookmarkDetail {

View File

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

@ -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
}
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)
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)
// Fallback-Filterung auf Client-Seite falls API keine Query-Parameter unterstützt
if let state = state {
return allBookmarks.filter { bookmark in
allBookmarks.bookmarks = allBookmarks.bookmarks.filter { bookmark in
switch state {
case .all:
return true
case .unread:
return !bookmark.isArchived && !bookmark.isMarked
case .favorite:

View File

@ -82,22 +82,11 @@ 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)
@ -111,6 +100,14 @@ 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)
}
}
@ -121,7 +118,6 @@ 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,154 +1,164 @@
import Foundation
import Combine
import Foundation
import SwiftUI
struct BookmarksView: View {
// MARK: States
@State private var viewModel = BookmarksViewModel()
@State private var showingAddBookmark = false
@State private var selectedBookmarkId: String?
let state: BookmarkState
@Binding var selectedBookmark: Bookmark?
@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
@Environment(\.horizontalSizeClass) var horizontalSizeClass
@Environment(\.verticalSizeClass) var verticalSizeClass
var body: some View {
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 {
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 {
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()
}
}
} else {
selectedBookmark = bookmark
}
}
.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.isEmpty && !viewModel.isLoading {
ContentUnavailableView(
"Keine Bookmarks",
systemImage: "bookmark",
description: Text("Es wurden noch keine Bookmarks in \(state.displayName.lowercased()) gefunden.")
}) {
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)
}
}
)
}
}
}
// 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)
.onAppear {
if bookmark.id == viewModel.bookmarks?.bookmarks.last?.id {
Task {
await viewModel.loadMoreBookmarks()
}
}
}
.padding(.trailing, 20)
.padding(.bottom, 20)
}
.buttonStyle(PlainButtonStyle())
.listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16))
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
}
}
}
.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
.listStyle(.plain)
.refreshable {
await viewModel.refreshBookmarks()
}
} message: {
Text(viewModel.errorMessage ?? "")
}*/
.onAppear {
Task {
await viewModel.loadBookmarks(state: state)
.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."
)
)
}
}
.searchable(
text: $viewModel.searchQuery, placement: .automatic, prompt: "Search...")
}
.onChange(of: showingAddBookmark) { oldValue, newValue in
// Refresh bookmarks when sheet is dismissed
if oldValue && !newValue {
Task {
await viewModel.loadBookmarks(state: state)
// 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)
}
}
}
}
.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 deleteBookmarkUseCase = DefaultUseCaseFactory.shared.makeDeleteBookmarkUseCase()
var bookmarks: [Bookmark] = []
var bookmarks: BookmarksPage?
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 {
throttleSearch()
}
}
private var searchWorkItem: DispatchWorkItem?
init() {
setupNotificationObserver()
@ -88,13 +86,14 @@ class BookmarksViewModel {
state: state,
limit: limit,
offset: offset,
search: searchQuery // Suche integrieren
search: searchQuery,
type: type
)
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 {
errorMessage = "Fehler beim Laden der Bookmarks"
bookmarks = []
bookmarks = nil
}
isLoading = false
@ -110,8 +109,8 @@ class BookmarksViewModel {
do {
offset += limit // Offset erhöhen
let newBookmarks = try await getBooksmarksUseCase.execute(state: currentState, limit: limit, offset: offset)
bookmarks.append(contentsOf: newBookmarks) // Neue Bookmarks hinzufügen
hasMoreData = newBookmarks.count == limit // Prüfen, ob weitere Daten verfügbar sind
bookmarks?.bookmarks.append(contentsOf: newBookmarks.bookmarks) // Neue Bookmarks hinzufügen
hasMoreData = newBookmarks.bookmarks.count == limit // Prüfen,
} catch {
errorMessage = "Fehler beim Nachladen der Bookmarks"
}
@ -163,7 +162,7 @@ class BookmarksViewModel {
try await deleteBookmarkUseCase.execute(bookmarkId: bookmark.id)
// Lokal aus der Liste entfernen (optimistische Update)
bookmarks.removeAll { $0.id == bookmark.id }
bookmarks?.bookmarks.removeAll { $0.id == bookmark.id }
} catch {
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 {
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:
@ -16,6 +26,8 @@ enum BookmarkState: String, CaseIterable {
var systemImage: String {
switch self {
case .all:
return "list.bullet"
case .unread:
return "house"
case .favorite:
@ -24,4 +36,4 @@ enum BookmarkState: String, CaseIterable {
return "archivebox"
}
}
}
}

View File

@ -1,3 +1,12 @@
//
// 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?
@ -14,12 +23,14 @@ struct PadSidebarView: View {
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
if tab == .article {
Spacer()
if tab == .archived {
Spacer(minLength: 20)
}
if tab == .pictures {
Spacer(minLength: 30)
Divider()
Spacer()
}
}
.listRowBackground(selectedTab == tab ? Color.accentColor.opacity(0.15) : Color.clear)
@ -46,21 +57,21 @@ struct PadSidebarView: View {
} content: {
switch selectedTab {
case .all:
Text("All")
BookmarksView(state: .all, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark)
case .unread:
BookmarksView(state: .unread, selectedBookmark: $selectedBookmark)
BookmarksView(state: .unread, type: [.article], selectedBookmark: $selectedBookmark)
case .favorite:
BookmarksView(state: .favorite, selectedBookmark: $selectedBookmark)
BookmarksView(state: .favorite, type: [.article], selectedBookmark: $selectedBookmark)
case .archived:
BookmarksView(state: .archived, selectedBookmark: $selectedBookmark)
BookmarksView(state: .archived, type: [.article], selectedBookmark: $selectedBookmark)
case .settings:
SettingsView()
case .article:
Text("Artikel")
BookmarksView(state: .all, type: [.article], selectedBookmark: $selectedBookmark)
case .videos:
Text("Videos")
BookmarksView(state: .all, type: [.video], selectedBookmark: $selectedBookmark)
case .pictures:
Text("Pictures")
BookmarksView(state: .all, type: [.photo], selectedBookmark: $selectedBookmark)
case .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 {
var body: some View {
TabView {
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 {
Label("Ungelesen", systemImage: "house")
}
BookmarksView(state: .favorite, selectedBookmark: .constant(nil))
BookmarksView(state: .favorite, type: [.article], selectedBookmark: .constant(nil))
.tabItem {
Label("Favoriten", systemImage: "heart")
}
BookmarksView(state: .archived, selectedBookmark: .constant(nil))
BookmarksView(state: .archived, type: [.article], selectedBookmark: .constant(nil))
.tabItem {
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 {
case all, unread, favorite, archived, settings, article, videos, pictures, tags
@ -30,4 +37,4 @@ enum SidebarTab: Hashable, CaseIterable, Identifiable {
case .tags: return "tag"
}
}
}
}

View File

@ -1,34 +1,6 @@
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: SidebarTab = .unread
@State var selectedBookmark: Bookmark?
@ -42,162 +14,13 @@ struct MainTabView: View {
var body: some View {
if UIDevice.isPhone {
PhoneView()
PhoneTabView()
} else {
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 {
MainTabView()
}

View File

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

View File

@ -9,7 +9,7 @@ import SwiftUI
// SectionHeader wird jetzt zentral importiert
struct SettingsGeneralView: View {
@State var viewModel: SettingsViewModel
@State private var viewModel = SettingsGeneralViewModel()
var body: some View {
VStack(spacing: 20) {
@ -28,10 +28,6 @@ struct SettingsGeneralView: View {
.pickerStyle(.segmented)
}
// Font Settings
FontSettingsView()
.padding(.vertical, 4)
// Sync Settings
VStack(alignment: .leading, spacing: 12) {
Text("Sync-Einstellungen")
@ -118,26 +114,19 @@ struct SettingsGeneralView: View {
// Save Button
Button(action: {
Task {
await viewModel.saveSettings()
await viewModel.saveGeneralSettings()
}
}) {
HStack {
if viewModel.isSaving {
ProgressView()
.scaleEffect(0.8)
.progressViewStyle(CircularProgressViewStyle(tint: .white))
}
Text(viewModel.isSaving ? "Speichere..." : "Einstellungen speichern")
Text("Einstellungen speichern")
.fontWeight(.semibold)
}
.frame(maxWidth: .infinity)
.padding()
.background(viewModel.canSave ? Color.accentColor : Color.gray)
.background(Color.accentColor)
.foregroundColor(.white)
.cornerRadius(10)
}
.disabled(!viewModel.canSave || viewModel.isSaving)
// Messages
if let successMessage = viewModel.successMessage {
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 {
SettingsGeneralView(viewModel: SettingsViewModel())
SettingsGeneralView()
}

View File

@ -37,13 +37,13 @@ class SettingsGeneralViewModel {
do {
if let settings = try await loadSettingsUseCase.execute() {
selectedTheme = .system // settings.theme ?? .system
autoSyncEnabled = settings.autoSyncEnabled
syncInterval = settings.syncInterval
enableReaderMode = settings.enableReaderMode
openExternalLinksInApp = settings.openExternalLinksInApp
autoMarkAsRead = settings.autoMarkAsRead
appVersion = settings.appVersion ?? "1.0.0"
developerName = settings.developerName ?? "Your Name"
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"
@ -53,14 +53,17 @@ class SettingsGeneralViewModel {
@MainActor
func saveGeneralSettings() async {
do {
try await saveSettingsUseCase.execute(
// 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"

View File

@ -9,7 +9,7 @@ import SwiftUI
// SectionHeader wird jetzt zentral importiert
struct SettingsServerView: View {
@State var viewModel = SettingsViewModel()
@State private var viewModel = SettingsServerViewModel()
@State private var isTesting: Bool = false
@State private var connectionTestSuccess: Bool = false
@State private var showingLogoutAlert = false
@ -188,38 +188,18 @@ 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
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)"
}
connectionTestSuccess = await viewModel.testConnection()
isTesting = false
}
}
#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
func login() async {
isLoading = true
@ -103,4 +131,4 @@ class SettingsServerViewModel {
var canLogin: Bool {
!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()
.environment(\.managedObjectContext, persistenceController.container.viewContext)
} else {
SettingsContainerView()
SettingsServerView()
.padding()
}
}
.onOpenURL { url in