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:
parent
7df56687c7
commit
be68538da3
@ -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 {
|
||||||
|
|||||||
6
readeck/Data/API/DTOs/CreateBookmarkResponseDto.swift
Normal file
6
readeck/Data/API/DTOs/CreateBookmarkResponseDto.swift
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct CreateBookmarkResponseDto: Codable {
|
||||||
|
let message: String
|
||||||
|
let status: Int
|
||||||
|
}
|
||||||
11
readeck/Data/API/DTOs/UserDto.swift
Normal file
11
readeck/Data/API/DTOs/UserDto.swift
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct UserDto: Codable {
|
||||||
|
let id: String
|
||||||
|
let token: String
|
||||||
|
|
||||||
|
enum CodingKeys: String, CodingKey {
|
||||||
|
case id
|
||||||
|
case token
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
7
readeck/Domain/Model/BookmarkType.swift
Normal file
7
readeck/Domain/Model/BookmarkType.swift
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
public enum BookmarkType: String, CaseIterable, Codable {
|
||||||
|
case article
|
||||||
|
case photo
|
||||||
|
case video
|
||||||
|
}
|
||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -1,7 +0,0 @@
|
|||||||
//
|
|
||||||
// File.swift
|
|
||||||
// readeck
|
|
||||||
//
|
|
||||||
// Created by Ilyas Hallak on 25.06.25.
|
|
||||||
//
|
|
||||||
|
|
||||||
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -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
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user