feat: Implement bookmark filtering, enhanced UI, and API integration
- Add BookmarkState enum with unread, favorite, and archived states - Extend API layer with query parameter filtering for bookmark states - Update Bookmark domain model to match complete API response schema - Implement BookmarkListView with card-based UI and preview images - Add BookmarkListViewModel with state management and error handling - Enhance BookmarkDetailView with meta information and WebView rendering - Create comprehensive DTO mapping for all bookmark fields - Add TabView with state-based bookmark filtering - Implement date formatting utilities for ISO8601 timestamps - Add progress indicators and pull-to-refresh functionality
This commit is contained in:
parent
98a914cb2e
commit
c8368f0a70
Binary file not shown.
@ -7,133 +7,172 @@
|
||||
|
||||
import Foundation
|
||||
|
||||
enum APIError: Error {
|
||||
case invalidURL
|
||||
case networkError
|
||||
case invalidResponse
|
||||
case authenticationFailed
|
||||
}
|
||||
|
||||
protocol PAPI {
|
||||
var tokenProvider: TokenProvider { get }
|
||||
func login(username: String, password: String) async throws -> UserDto
|
||||
func getBookmarks() async throws -> [BookmarkDto]
|
||||
func getBookmarks(state: BookmarkState?) async throws -> [BookmarkDto]
|
||||
func getBookmark(id: String) async throws -> BookmarkDetailDto
|
||||
func getBookmarkArticle(id: String) async throws -> String
|
||||
var authToken: String? { get set }
|
||||
}
|
||||
|
||||
class API: PAPI {
|
||||
private let baseURL: String
|
||||
var authToken: String?
|
||||
let tokenProvider: TokenProvider
|
||||
private var cachedBaseURL: String?
|
||||
|
||||
init(baseURL: String) {
|
||||
self.baseURL = baseURL
|
||||
init(tokenProvider: TokenProvider = CoreDataTokenProvider()) {
|
||||
self.tokenProvider = tokenProvider
|
||||
}
|
||||
|
||||
private var baseURL: String {
|
||||
get async {
|
||||
if let cached = cachedBaseURL {
|
||||
return cached
|
||||
}
|
||||
let url = await tokenProvider.getEndpoint()
|
||||
cachedBaseURL = url
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
// Separate Methode für JSON-Requests
|
||||
private func makeJSONRequest<T: Codable>(
|
||||
endpoint: String,
|
||||
method: HTTPMethod = .GET,
|
||||
body: Data? = nil,
|
||||
responseType: T.Type
|
||||
) async throws -> T {
|
||||
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)
|
||||
}
|
||||
|
||||
return try JSONDecoder().decode(T.self, from: data)
|
||||
}
|
||||
|
||||
// Separate Methode für String-Requests (HTML/Text)
|
||||
private func makeStringRequest(
|
||||
endpoint: String,
|
||||
method: HTTPMethod = .GET
|
||||
) async throws -> String {
|
||||
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
|
||||
|
||||
if let token = await tokenProvider.getToken() {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
}
|
||||
|
||||
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 {
|
||||
throw APIError.serverError(httpResponse.statusCode)
|
||||
}
|
||||
|
||||
// Als String dekodieren statt als JSON
|
||||
guard let string = String(data: data, encoding: .utf8) else {
|
||||
throw APIError.invalidResponse
|
||||
}
|
||||
|
||||
return string
|
||||
}
|
||||
|
||||
func login(username: String, password: String) async throws -> UserDto {
|
||||
guard let url = URL(string: "\(baseURL)/auth") else {
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
let loginRequest = LoginRequestDto(application: "api doc", username: username, password: password)
|
||||
let requestData = try JSONEncoder().encode(loginRequest)
|
||||
|
||||
let credentials = [
|
||||
"username": username,
|
||||
"password": password,
|
||||
"application": "api doc"
|
||||
]
|
||||
let userDto = try await makeJSONRequest(
|
||||
endpoint: "/api/auth",
|
||||
method: .POST,
|
||||
body: requestData,
|
||||
responseType: UserDto.self
|
||||
)
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("application/json", forHTTPHeaderField: "accept")
|
||||
request.httpBody = try? JSONSerialization.data(withJSONObject: credentials)
|
||||
// Token automatisch speichern nach erfolgreichem Login
|
||||
await tokenProvider.setToken(userDto.token)
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 201 else {
|
||||
throw APIError.authenticationFailed
|
||||
}
|
||||
|
||||
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
|
||||
let token = json["token"] as? String {
|
||||
self.authToken = token
|
||||
let decoder = JSONDecoder()
|
||||
return try decoder.decode(UserDto.self, from: data)
|
||||
}
|
||||
|
||||
throw APIError.invalidResponse
|
||||
return userDto
|
||||
}
|
||||
|
||||
// Bookmarks abrufen
|
||||
func getBookmarks() async throws -> [BookmarkDto] {
|
||||
guard let url = URL(string: "\(baseURL)/bookmarks") else {
|
||||
throw APIError.invalidURL
|
||||
func getBookmarks(state: BookmarkState? = nil) async throws -> [BookmarkDto] {
|
||||
var endpoint = "/api/bookmarks"
|
||||
|
||||
// Query-Parameter basierend auf State hinzufügen
|
||||
if let state = state {
|
||||
switch state {
|
||||
case .unread:
|
||||
endpoint += "?is_archived=false&is_marked=false"
|
||||
case .favorite:
|
||||
endpoint += "?is_marked=true"
|
||||
case .archived:
|
||||
endpoint += "?is_archived=true"
|
||||
}
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
request.setValue("application/json", forHTTPHeaderField: "accept")
|
||||
|
||||
if let token = authToken {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "authorization")
|
||||
}
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
throw APIError.networkError
|
||||
}
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
return try decoder.decode([BookmarkDto].self, from: data)
|
||||
return try await makeJSONRequest(
|
||||
endpoint: endpoint,
|
||||
responseType: [BookmarkDto].self
|
||||
)
|
||||
}
|
||||
|
||||
func getBookmark(id: String) async throws -> BookmarkDetailDto {
|
||||
guard let url = URL(string: "\(baseURL)/bookmarks/\(id)") else {
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
request.setValue("application/json", forHTTPHeaderField: "accept")
|
||||
|
||||
if let token = authToken {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "authorization")
|
||||
}
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200 else {
|
||||
throw APIError.networkError
|
||||
}
|
||||
|
||||
let decoder = JSONDecoder()
|
||||
return try decoder.decode(BookmarkDetailDto.self, from: data)
|
||||
return try await makeJSONRequest(
|
||||
endpoint: "/api/bookmarks/\(id)",
|
||||
responseType: BookmarkDetailDto.self
|
||||
)
|
||||
}
|
||||
|
||||
// Artikel als String laden statt als JSON
|
||||
func getBookmarkArticle(id: String) async throws -> String {
|
||||
guard let url = URL(string: "\(baseURL)/bookmarks/\(id)/article") else {
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "GET"
|
||||
request.setValue("text/html", forHTTPHeaderField: "accept")
|
||||
|
||||
if let token = authToken {
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "authorization")
|
||||
}
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
|
||||
guard let httpResponse = response as? HTTPURLResponse,
|
||||
httpResponse.statusCode == 200,
|
||||
let htmlContent = String(data: data, encoding: .utf8) else {
|
||||
throw APIError.networkError
|
||||
}
|
||||
|
||||
return htmlContent
|
||||
return try await makeStringRequest(
|
||||
endpoint: "/api/bookmarks/\(id)/article"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum HTTPMethod: String {
|
||||
case GET = "GET"
|
||||
case POST = "POST"
|
||||
case PUT = "PUT"
|
||||
case DELETE = "DELETE"
|
||||
}
|
||||
|
||||
enum APIError: Error {
|
||||
case invalidURL
|
||||
case invalidResponse
|
||||
case serverError(Int)
|
||||
}
|
||||
|
||||
4
readeck/Data/API/DTOs/BookmarkDetailDto.swift
Normal file
4
readeck/Data/API/DTOs/BookmarkDetailDto.swift
Normal file
@ -0,0 +1,4 @@
|
||||
import Foundation
|
||||
|
||||
// BookmarkDetailDto ist identisch mit BookmarkDto
|
||||
typealias BookmarkDetailDto = BookmarkDto
|
||||
64
readeck/Data/API/DTOs/BookmarkDto.swift
Normal file
64
readeck/Data/API/DTOs/BookmarkDto.swift
Normal file
@ -0,0 +1,64 @@
|
||||
import Foundation
|
||||
|
||||
struct BookmarkDto: Codable {
|
||||
let id: String
|
||||
let title: String
|
||||
let url: String
|
||||
let href: String
|
||||
let description: String
|
||||
let authors: [String]
|
||||
let created: String
|
||||
let published: String?
|
||||
let updated: String
|
||||
let siteName: String
|
||||
let site: String
|
||||
let readingTime: Int?
|
||||
let wordCount: Int
|
||||
let hasArticle: Bool
|
||||
let isArchived: Bool
|
||||
let isDeleted: Bool
|
||||
let isMarked: Bool
|
||||
let labels: [String]
|
||||
let lang: String?
|
||||
let loaded: Bool
|
||||
let readProgress: Int
|
||||
let documentType: String
|
||||
let state: Int
|
||||
let textDirection: String
|
||||
let type: String
|
||||
let resources: BookmarkResourcesDto
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, title, url, href, description, authors, created, published, updated, site, labels, lang, loaded, state, type
|
||||
case siteName = "site_name"
|
||||
case readingTime = "reading_time"
|
||||
case wordCount = "word_count"
|
||||
case hasArticle = "has_article"
|
||||
case isArchived = "is_archived"
|
||||
case isDeleted = "is_deleted"
|
||||
case isMarked = "is_marked"
|
||||
case readProgress = "read_progress"
|
||||
case documentType = "document_type"
|
||||
case textDirection = "text_direction"
|
||||
case resources
|
||||
}
|
||||
}
|
||||
|
||||
struct BookmarkResourcesDto: Codable {
|
||||
let article: ResourceDto?
|
||||
let icon: ImageResourceDto?
|
||||
let image: ImageResourceDto?
|
||||
let log: ResourceDto?
|
||||
let props: ResourceDto?
|
||||
let thumbnail: ImageResourceDto?
|
||||
}
|
||||
|
||||
struct ResourceDto: Codable {
|
||||
let src: String
|
||||
}
|
||||
|
||||
struct ImageResourceDto: Codable {
|
||||
let src: String
|
||||
let height: Int
|
||||
let width: Int
|
||||
}
|
||||
7
readeck/Data/API/DTOs/LoginRequestDto.swift
Normal file
7
readeck/Data/API/DTOs/LoginRequestDto.swift
Normal file
@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
struct LoginRequestDto: Codable {
|
||||
let application: String
|
||||
let username: String
|
||||
let password: String
|
||||
}
|
||||
32
readeck/Data/CoreData/CoreDataManager.swift
Normal file
32
readeck/Data/CoreData/CoreDataManager.swift
Normal file
@ -0,0 +1,32 @@
|
||||
import CoreData
|
||||
import Foundation
|
||||
|
||||
class CoreDataManager {
|
||||
static let shared = CoreDataManager()
|
||||
|
||||
private init() {}
|
||||
|
||||
lazy var persistentContainer: NSPersistentContainer = {
|
||||
let container = NSPersistentContainer(name: "readeck")
|
||||
container.loadPersistentStores { _, error in
|
||||
if let error = error {
|
||||
fatalError("Core Data error: \(error)")
|
||||
}
|
||||
}
|
||||
return container
|
||||
}()
|
||||
|
||||
var context: NSManagedObjectContext {
|
||||
return persistentContainer.viewContext
|
||||
}
|
||||
|
||||
func save() {
|
||||
if context.hasChanges {
|
||||
do {
|
||||
try context.save()
|
||||
} catch {
|
||||
print("Failed to save Core Data context: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,82 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct BookmarkDetailDto: Codable {
|
||||
let id: String
|
||||
let href: String
|
||||
let created: String
|
||||
let updated: String
|
||||
let state: Int
|
||||
let loaded: Bool
|
||||
let url: String
|
||||
let title: String
|
||||
let siteName: String
|
||||
let site: String
|
||||
let authors: [String]
|
||||
let lang: String
|
||||
let textDirection: String
|
||||
let documentType: String
|
||||
let type: String
|
||||
let hasArticle: Bool
|
||||
let description: String
|
||||
let isDeleted: Bool
|
||||
let isMarked: Bool
|
||||
let isArchived: Bool
|
||||
let labels: [String]
|
||||
let readProgress: Int
|
||||
let resources: Resources
|
||||
let links: [Link]
|
||||
let wordCount: Int
|
||||
let readingTime: Int
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, href, created, updated, state, loaded, url, title
|
||||
case siteName = "site_name"
|
||||
case site, authors, lang
|
||||
case textDirection = "text_direction"
|
||||
case documentType = "document_type"
|
||||
case type
|
||||
case hasArticle = "has_article"
|
||||
case description
|
||||
case isDeleted = "is_deleted"
|
||||
case isMarked = "is_marked"
|
||||
case isArchived = "is_archived"
|
||||
case labels
|
||||
case readProgress = "read_progress"
|
||||
case resources, links
|
||||
case wordCount = "word_count"
|
||||
case readingTime = "reading_time"
|
||||
}
|
||||
|
||||
struct Resources: Codable {
|
||||
let article: Resource
|
||||
let icon: ResourceWithDimensions
|
||||
let image: ResourceWithDimensions
|
||||
let log: Resource
|
||||
let props: Resource
|
||||
let thumbnail: ResourceWithDimensions
|
||||
}
|
||||
|
||||
struct Resource: Codable {
|
||||
let src: String
|
||||
}
|
||||
|
||||
struct ResourceWithDimensions: Codable {
|
||||
let src: String
|
||||
let width: Int
|
||||
let height: Int
|
||||
}
|
||||
|
||||
struct Link: Codable {
|
||||
let url: String
|
||||
let domain: String
|
||||
let title: String
|
||||
let isPage: Bool
|
||||
let contentType: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case url, domain, title
|
||||
case isPage = "is_page"
|
||||
case contentType = "content_type"
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
struct BookmarkDto: Codable {
|
||||
let id: String
|
||||
let title: String
|
||||
let url: String
|
||||
let createdAt: String
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id
|
||||
case title
|
||||
case url
|
||||
case createdAt = "created"
|
||||
}
|
||||
}
|
||||
61
readeck/Data/Mappers/BookmarkMapper.swift
Normal file
61
readeck/Data/Mappers/BookmarkMapper.swift
Normal file
@ -0,0 +1,61 @@
|
||||
import Foundation
|
||||
|
||||
// MARK: - BookmarkDto to Domain Mapping
|
||||
extension BookmarkDto {
|
||||
func toDomain() -> Bookmark {
|
||||
return Bookmark(
|
||||
id: id,
|
||||
title: title,
|
||||
url: url,
|
||||
href: href,
|
||||
description: description,
|
||||
authors: authors,
|
||||
created: created,
|
||||
published: published,
|
||||
updated: updated,
|
||||
siteName: siteName,
|
||||
site: site,
|
||||
readingTime: readingTime,
|
||||
wordCount: wordCount,
|
||||
hasArticle: hasArticle,
|
||||
isArchived: isArchived,
|
||||
isDeleted: isDeleted,
|
||||
isMarked: isMarked,
|
||||
labels: labels,
|
||||
lang: lang,
|
||||
loaded: loaded,
|
||||
readProgress: readProgress,
|
||||
documentType: documentType,
|
||||
state: state,
|
||||
textDirection: textDirection,
|
||||
type: type,
|
||||
resources: resources.toDomain()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Resources Mapping
|
||||
extension BookmarkResourcesDto {
|
||||
func toDomain() -> BookmarkResources {
|
||||
return BookmarkResources(
|
||||
article: article?.toDomain(),
|
||||
icon: icon?.toDomain(),
|
||||
image: image?.toDomain(),
|
||||
log: log?.toDomain(),
|
||||
props: props?.toDomain(),
|
||||
thumbnail: thumbnail?.toDomain()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
extension ResourceDto {
|
||||
func toDomain() -> Resource {
|
||||
return Resource(src: src)
|
||||
}
|
||||
}
|
||||
|
||||
extension ImageResourceDto {
|
||||
func toDomain() -> ImageResource {
|
||||
return ImageResource(src: src, height: height, width: width)
|
||||
}
|
||||
}
|
||||
@ -2,22 +2,30 @@ import Foundation
|
||||
|
||||
class AuthRepository: PAuthRepository {
|
||||
private let api: PAPI
|
||||
|
||||
init(api: PAPI) {
|
||||
private let settingsRepository: PSettingsRepository
|
||||
|
||||
init(api: PAPI, settingsRepository: PSettingsRepository) {
|
||||
self.api = api
|
||||
self.settingsRepository = settingsRepository
|
||||
}
|
||||
|
||||
func login(username: String, password: String) async throws -> User {
|
||||
let userDto = try await api.login(username: username, password: password)
|
||||
UserDefaults.standard.set(userDto.token, forKey: "token")
|
||||
UserDefaults.standard.synchronize()
|
||||
// Token wird automatisch von der API gespeichert
|
||||
return User(id: userDto.id, token: userDto.token)
|
||||
}
|
||||
|
||||
func logout() async throws {
|
||||
// Implement logout logic if needed
|
||||
await api.tokenProvider.clearToken()
|
||||
}
|
||||
|
||||
func getCurrentSettings() async throws -> Settings? {
|
||||
return try await settingsRepository.loadSettings()
|
||||
}
|
||||
|
||||
func saveSettings(_ settings: Settings) async throws {
|
||||
try await settingsRepository.saveSettings(settings)
|
||||
}
|
||||
}
|
||||
|
||||
struct User {
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import Foundation
|
||||
|
||||
protocol PBookmarksRepository {
|
||||
func fetchBookmarks() async throws -> [Bookmark]
|
||||
func fetchBookmarks(state: BookmarkState?) async throws -> [Bookmark]
|
||||
func fetchBookmark(id: String) async throws -> BookmarkDetail
|
||||
func fetchBookmarkArticle(id: String) async throws -> String
|
||||
func addBookmark(bookmark: Bookmark) async throws
|
||||
func removeBookmark(id: String) async throws
|
||||
}
|
||||
@ -13,12 +15,34 @@ class BookmarksRepository: PBookmarksRepository {
|
||||
self.api = api
|
||||
}
|
||||
|
||||
func fetchBookmarks() async throws -> [Bookmark] {
|
||||
let bookmarkDtos = try await api.getBookmarks()
|
||||
api.authToken = UserDefaults.standard.string(forKey: "token")
|
||||
return bookmarkDtos.map { dto in
|
||||
Bookmark(id: dto.id, title: dto.title, url: dto.url, createdAt: dto.createdAt)
|
||||
}
|
||||
func fetchBookmarks(state: BookmarkState? = nil) async throws -> [Bookmark] {
|
||||
let bookmarkDtos = try await api.getBookmarks(state: state)
|
||||
return bookmarkDtos.map { $0.toDomain() }
|
||||
}
|
||||
|
||||
func fetchBookmark(id: String) async throws -> BookmarkDetail {
|
||||
let bookmarkDetailDto = try await api.getBookmark(id: id)
|
||||
return BookmarkDetail(
|
||||
id: bookmarkDetailDto.id,
|
||||
title: bookmarkDetailDto.title,
|
||||
url: bookmarkDetailDto.url,
|
||||
description: bookmarkDetailDto.description,
|
||||
siteName: bookmarkDetailDto.siteName,
|
||||
authors: bookmarkDetailDto.authors,
|
||||
created: bookmarkDetailDto.created,
|
||||
updated: bookmarkDetailDto.updated,
|
||||
wordCount: bookmarkDetailDto.wordCount,
|
||||
readingTime: bookmarkDetailDto.readingTime,
|
||||
hasArticle: bookmarkDetailDto.hasArticle,
|
||||
isMarked: bookmarkDetailDto.isMarked,
|
||||
isArchived: bookmarkDetailDto.isArchived,
|
||||
thumbnailUrl: bookmarkDetailDto.resources.thumbnail?.src ?? "",
|
||||
imageUrl: bookmarkDetailDto.resources.image?.src ?? ""
|
||||
)
|
||||
}
|
||||
|
||||
func fetchBookmarkArticle(id: String) async throws -> String {
|
||||
return try await api.getBookmarkArticle(id: id)
|
||||
}
|
||||
|
||||
func addBookmark(bookmark: Bookmark) async throws {
|
||||
@ -30,9 +54,20 @@ class BookmarksRepository: PBookmarksRepository {
|
||||
}
|
||||
}
|
||||
|
||||
struct Bookmark {
|
||||
struct BookmarkDetail {
|
||||
let id: String
|
||||
let title: String
|
||||
let url: String
|
||||
let createdAt: String
|
||||
let description: String
|
||||
let siteName: String
|
||||
let authors: [String]
|
||||
let created: String
|
||||
let updated: String
|
||||
let wordCount: Int
|
||||
let readingTime: Int?
|
||||
let hasArticle: Bool
|
||||
let isMarked: Bool
|
||||
let isArchived: Bool
|
||||
let thumbnailUrl: String
|
||||
let imageUrl: String
|
||||
}
|
||||
|
||||
136
readeck/Data/Repository/SettingsRepository.swift
Normal file
136
readeck/Data/Repository/SettingsRepository.swift
Normal file
@ -0,0 +1,136 @@
|
||||
import Foundation
|
||||
import CoreData
|
||||
|
||||
struct Settings {
|
||||
let endpoint: String
|
||||
let username: String
|
||||
let password: String
|
||||
var token: String?
|
||||
|
||||
var isLoggedIn: Bool {
|
||||
token != nil && !token!.isEmpty
|
||||
}
|
||||
|
||||
mutating func setToken(_ newToken: String) {
|
||||
token = newToken
|
||||
}
|
||||
}
|
||||
|
||||
protocol PSettingsRepository {
|
||||
func saveSettings(_ settings: Settings) async throws
|
||||
func loadSettings() async throws -> Settings?
|
||||
func clearSettings() async throws
|
||||
func saveToken(_ token: String) async throws
|
||||
}
|
||||
|
||||
class SettingsRepository: PSettingsRepository {
|
||||
private let coreDataManager = CoreDataManager.shared
|
||||
|
||||
func saveSettings(_ settings: Settings) async throws {
|
||||
let context = coreDataManager.context
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
context.perform {
|
||||
do {
|
||||
// Vorhandene Einstellungen löschen
|
||||
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
|
||||
let existingSettings = try context.fetch(fetchRequest)
|
||||
for setting in existingSettings {
|
||||
context.delete(setting)
|
||||
}
|
||||
|
||||
// Neue Einstellungen erstellen
|
||||
let settingEntity = SettingEntity(context: context)
|
||||
settingEntity.endpoint = settings.endpoint
|
||||
settingEntity.username = settings.username
|
||||
settingEntity.password = settings.password
|
||||
settingEntity.token = settings.token
|
||||
|
||||
try context.save()
|
||||
continuation.resume()
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func loadSettings() async throws -> Settings? {
|
||||
let context = coreDataManager.context
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
context.perform {
|
||||
do {
|
||||
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
|
||||
fetchRequest.fetchLimit = 1
|
||||
|
||||
let settingEntities = try context.fetch(fetchRequest)
|
||||
|
||||
if let settingEntity = settingEntities.first {
|
||||
let settings = Settings(
|
||||
endpoint: settingEntity.endpoint ?? "",
|
||||
username: settingEntity.username ?? "",
|
||||
password: settingEntity.password ?? "",
|
||||
token: settingEntity.token
|
||||
)
|
||||
continuation.resume(returning: settings)
|
||||
} else {
|
||||
continuation.resume(returning: nil)
|
||||
}
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func clearSettings() async throws {
|
||||
let context = coreDataManager.context
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
context.perform {
|
||||
do {
|
||||
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
|
||||
let settingEntities = try context.fetch(fetchRequest)
|
||||
|
||||
for settingEntity in settingEntities {
|
||||
context.delete(settingEntity)
|
||||
}
|
||||
|
||||
try context.save()
|
||||
continuation.resume()
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func saveToken(_ token: String) async throws {
|
||||
let context = coreDataManager.context
|
||||
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
context.perform {
|
||||
do {
|
||||
let fetchRequest: NSFetchRequest<SettingEntity> = SettingEntity.fetchRequest()
|
||||
fetchRequest.fetchLimit = 1
|
||||
|
||||
let settingEntities = try context.fetch(fetchRequest)
|
||||
|
||||
if let settingEntity = settingEntities.first {
|
||||
settingEntity.token = token
|
||||
} else {
|
||||
// Fallback: Neue Einstellung erstellen (sollte normalerweise nicht passieren)
|
||||
let settingEntity = SettingEntity(context: context)
|
||||
settingEntity.token = token
|
||||
}
|
||||
|
||||
try context.save()
|
||||
continuation.resume()
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
45
readeck/Data/TokenManager.swift
Normal file
45
readeck/Data/TokenManager.swift
Normal file
@ -0,0 +1,45 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
class TokenManager {
|
||||
static let shared = TokenManager()
|
||||
|
||||
private let settingsRepository = SettingsRepository()
|
||||
private var cachedSettings: Settings?
|
||||
|
||||
private init() {}
|
||||
|
||||
var currentToken: String? {
|
||||
cachedSettings?.token
|
||||
}
|
||||
|
||||
var currentEndpoint: String? {
|
||||
cachedSettings?.endpoint
|
||||
}
|
||||
|
||||
func loadSettings() async {
|
||||
do {
|
||||
cachedSettings = try await settingsRepository.loadSettings()
|
||||
} catch {
|
||||
print("Failed to load settings: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func updateToken(_ token: String) async {
|
||||
do {
|
||||
try await settingsRepository.saveToken(token)
|
||||
cachedSettings?.token = token
|
||||
} catch {
|
||||
print("Failed to save token: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func clearToken() async {
|
||||
do {
|
||||
try await settingsRepository.clearSettings()
|
||||
cachedSettings = nil
|
||||
} catch {
|
||||
print("Failed to clear settings: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
59
readeck/Data/TokenProvider.swift
Normal file
59
readeck/Data/TokenProvider.swift
Normal file
@ -0,0 +1,59 @@
|
||||
import Foundation
|
||||
|
||||
protocol TokenProvider {
|
||||
func getToken() async -> String?
|
||||
func getEndpoint() async -> String
|
||||
func setToken(_ token: String) async
|
||||
func clearToken() async
|
||||
}
|
||||
|
||||
class CoreDataTokenProvider: TokenProvider {
|
||||
private let settingsRepository = SettingsRepository()
|
||||
private var cachedSettings: Settings?
|
||||
private var isLoaded = false
|
||||
|
||||
private func loadSettingsIfNeeded() async {
|
||||
guard !isLoaded else { return }
|
||||
|
||||
do {
|
||||
cachedSettings = try await settingsRepository.loadSettings()
|
||||
isLoaded = true
|
||||
} catch {
|
||||
print("Failed to load settings: \(error)")
|
||||
cachedSettings = nil
|
||||
}
|
||||
}
|
||||
|
||||
func getToken() async -> String? {
|
||||
await loadSettingsIfNeeded()
|
||||
return cachedSettings?.token
|
||||
}
|
||||
|
||||
func getEndpoint() async -> String {
|
||||
await loadSettingsIfNeeded()
|
||||
// Basis-URL ohne /api Suffix, da es in der API-Klasse hinzugefügt wird
|
||||
return cachedSettings?.endpoint ?? "https://keep.mnk.any64.de"
|
||||
}
|
||||
|
||||
func setToken(_ token: String) async {
|
||||
await loadSettingsIfNeeded()
|
||||
|
||||
do {
|
||||
try await settingsRepository.saveToken(token)
|
||||
if cachedSettings != nil {
|
||||
cachedSettings!.token = token
|
||||
}
|
||||
} catch {
|
||||
print("Failed to save token: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
func clearToken() async {
|
||||
do {
|
||||
try await settingsRepository.clearSettings()
|
||||
cachedSettings = nil
|
||||
} catch {
|
||||
print("Failed to clear settings: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -2,23 +2,49 @@ import Foundation
|
||||
|
||||
protocol UseCaseFactory {
|
||||
func makeLoginUseCase() -> LoginUseCase
|
||||
func makeGetBooksmarksUseCase() -> GetBooksmarksUseCase
|
||||
func makeGetBookmarksUseCase() -> GetBookmarksUseCase
|
||||
func makeGetBookmarkUseCase() -> GetBookmarkUseCase
|
||||
func makeGetBookmarkArticleUseCase() -> GetBookmarkArticleUseCase
|
||||
func makeSaveSettingsUseCase() -> SaveSettingsUseCase
|
||||
func makeLoadSettingsUseCase() -> LoadSettingsUseCase
|
||||
}
|
||||
|
||||
class DefaultUseCaseFactory: UseCaseFactory {
|
||||
private let api: PAPI
|
||||
private let tokenProvider = CoreDataTokenProvider()
|
||||
private lazy var api: PAPI = API(tokenProvider: tokenProvider)
|
||||
private lazy var authRepository: PAuthRepository = AuthRepository(api: api, settingsRepository: SettingsRepository())
|
||||
private lazy var bookmarksRepository: PBookmarksRepository = BookmarksRepository(api: api)
|
||||
|
||||
static let shared = DefaultUseCaseFactory()
|
||||
|
||||
init(api: PAPI = API(baseURL: "https://keep.mnk.any64.de/api")) {
|
||||
self.api = api
|
||||
}
|
||||
private init() {}
|
||||
|
||||
func makeLoginUseCase() -> LoginUseCase {
|
||||
LoginUseCase(repository: AuthRepository(api: api))
|
||||
LoginUseCase(repository: authRepository)
|
||||
}
|
||||
|
||||
func makeGetBooksmarksUseCase() -> GetBooksmarksUseCase {
|
||||
GetBooksmarksUseCase(repository: .init(api: api))
|
||||
func makeGetBookmarksUseCase() -> GetBookmarksUseCase {
|
||||
GetBookmarksUseCase(repository: bookmarksRepository)
|
||||
}
|
||||
|
||||
func makeGetBookmarkUseCase() -> GetBookmarkUseCase {
|
||||
GetBookmarkUseCase(repository: bookmarksRepository)
|
||||
}
|
||||
|
||||
func makeGetBookmarkArticleUseCase() -> GetBookmarkArticleUseCase {
|
||||
GetBookmarkArticleUseCase(repository: bookmarksRepository)
|
||||
}
|
||||
|
||||
func makeSaveSettingsUseCase() -> SaveSettingsUseCase {
|
||||
SaveSettingsUseCase(authRepository: authRepository)
|
||||
}
|
||||
|
||||
func makeLoadSettingsUseCase() -> LoadSettingsUseCase {
|
||||
LoadSettingsUseCase(authRepository: authRepository)
|
||||
}
|
||||
|
||||
// Nicht mehr nötig - Token wird automatisch geladen
|
||||
func refreshConfiguration() async {
|
||||
// Optional: Cache löschen falls nötig
|
||||
}
|
||||
}
|
||||
|
||||
49
readeck/Domain/Model/Bookmark.swift
Normal file
49
readeck/Domain/Model/Bookmark.swift
Normal file
@ -0,0 +1,49 @@
|
||||
import Foundation
|
||||
|
||||
struct Bookmark {
|
||||
let id: String
|
||||
let title: String
|
||||
let url: String
|
||||
let href: String
|
||||
let description: String
|
||||
let authors: [String]
|
||||
let created: String
|
||||
let published: String?
|
||||
let updated: String
|
||||
let siteName: String
|
||||
let site: String
|
||||
let readingTime: Int?
|
||||
let wordCount: Int
|
||||
let hasArticle: Bool
|
||||
let isArchived: Bool
|
||||
let isDeleted: Bool
|
||||
let isMarked: Bool
|
||||
let labels: [String]
|
||||
let lang: String?
|
||||
let loaded: Bool
|
||||
let readProgress: Int
|
||||
let documentType: String
|
||||
let state: Int
|
||||
let textDirection: String
|
||||
let type: String
|
||||
let resources: BookmarkResources
|
||||
}
|
||||
|
||||
struct BookmarkResources {
|
||||
let article: Resource?
|
||||
let icon: ImageResource?
|
||||
let image: ImageResource?
|
||||
let log: Resource?
|
||||
let props: Resource?
|
||||
let thumbnail: ImageResource?
|
||||
}
|
||||
|
||||
struct Resource {
|
||||
let src: String
|
||||
}
|
||||
|
||||
struct ImageResource {
|
||||
let src: String
|
||||
let height: Int
|
||||
let width: Int
|
||||
}
|
||||
@ -9,4 +9,6 @@
|
||||
protocol PAuthRepository {
|
||||
func login(username: String, password: String) async throws -> User
|
||||
func logout() async throws
|
||||
func getCurrentSettings() async throws -> Settings?
|
||||
func saveSettings(_ settings: Settings) async throws
|
||||
}
|
||||
|
||||
13
readeck/Domain/UseCase/GetBookmarkArticleUseCase.swift
Normal file
13
readeck/Domain/UseCase/GetBookmarkArticleUseCase.swift
Normal file
@ -0,0 +1,13 @@
|
||||
import Foundation
|
||||
|
||||
class GetBookmarkArticleUseCase {
|
||||
private let repository: PBookmarksRepository
|
||||
|
||||
init(repository: PBookmarksRepository) {
|
||||
self.repository = repository
|
||||
}
|
||||
|
||||
func execute(id: String) async throws -> String {
|
||||
return try await repository.fetchBookmarkArticle(id: id)
|
||||
}
|
||||
}
|
||||
13
readeck/Domain/UseCase/GetBookmarkUseCase.swift
Normal file
13
readeck/Domain/UseCase/GetBookmarkUseCase.swift
Normal file
@ -0,0 +1,13 @@
|
||||
import Foundation
|
||||
|
||||
class GetBookmarkUseCase {
|
||||
private let repository: PBookmarksRepository
|
||||
|
||||
init(repository: PBookmarksRepository) {
|
||||
self.repository = repository
|
||||
}
|
||||
|
||||
func execute(id: String) async throws -> BookmarkDetail {
|
||||
return try await repository.fetchBookmark(id: id)
|
||||
}
|
||||
}
|
||||
@ -1,12 +1,29 @@
|
||||
import Foundation
|
||||
|
||||
class GetBooksmarksUseCase {
|
||||
private let repository: BookmarksRepository
|
||||
|
||||
init(repository: BookmarksRepository) {
|
||||
class GetBookmarksUseCase {
|
||||
private let repository: PBookmarksRepository
|
||||
|
||||
init(repository: PBookmarksRepository) {
|
||||
self.repository = repository
|
||||
}
|
||||
|
||||
func execute() async throws -> [Bookmark] {
|
||||
return try await repository.fetchBookmarks()
|
||||
|
||||
func execute(state: BookmarkState? = nil) async throws -> [Bookmark] {
|
||||
let allBookmarks = try await repository.fetchBookmarks(state: state)
|
||||
|
||||
// Fallback-Filterung auf Client-Seite falls API keine Query-Parameter unterstützt
|
||||
if let state = state {
|
||||
return allBookmarks.filter { bookmark in
|
||||
switch state {
|
||||
case .unread:
|
||||
return !bookmark.isArchived && !bookmark.isMarked
|
||||
case .favorite:
|
||||
return bookmark.isMarked
|
||||
case .archived:
|
||||
return bookmark.isArchived
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allBookmarks
|
||||
}
|
||||
}
|
||||
|
||||
13
readeck/Domain/UseCase/LoadSettingsUseCase.swift
Normal file
13
readeck/Domain/UseCase/LoadSettingsUseCase.swift
Normal file
@ -0,0 +1,13 @@
|
||||
import Foundation
|
||||
|
||||
class LoadSettingsUseCase {
|
||||
private let authRepository: PAuthRepository
|
||||
|
||||
init(authRepository: PAuthRepository) {
|
||||
self.authRepository = authRepository
|
||||
}
|
||||
|
||||
func execute() async throws -> Settings? {
|
||||
return try await authRepository.getCurrentSettings()
|
||||
}
|
||||
}
|
||||
19
readeck/Domain/UseCase/SaveSettingsUseCase.swift
Normal file
19
readeck/Domain/UseCase/SaveSettingsUseCase.swift
Normal file
@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
class SaveSettingsUseCase {
|
||||
private let authRepository: PAuthRepository
|
||||
|
||||
init(authRepository: PAuthRepository) {
|
||||
self.authRepository = authRepository
|
||||
}
|
||||
|
||||
func execute(endpoint: String, username: String, password: String) async throws {
|
||||
let settings = Settings(
|
||||
endpoint: endpoint,
|
||||
username: username,
|
||||
password: password,
|
||||
token: nil
|
||||
)
|
||||
try await authRepository.saveSettings(settings)
|
||||
}
|
||||
}
|
||||
119
readeck/UI/BookmarkDetail/BookmarkDetailView.swift
Normal file
119
readeck/UI/BookmarkDetail/BookmarkDetailView.swift
Normal file
@ -0,0 +1,119 @@
|
||||
import SwiftUI
|
||||
|
||||
struct BookmarkDetailView: View {
|
||||
let bookmarkId: String
|
||||
@State private var viewModel = BookmarkDetailViewModel()
|
||||
@State private var webViewHeight: CGFloat = 300
|
||||
|
||||
var body: some View {
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
// Header mit Bild
|
||||
if !viewModel.bookmarkDetail.imageUrl.isEmpty {
|
||||
AsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl)) { image in
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
} placeholder: {
|
||||
Rectangle()
|
||||
.fill(Color.gray.opacity(0.3))
|
||||
.frame(height: 200)
|
||||
}
|
||||
.frame(height: 200)
|
||||
.clipped()
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
// Titel
|
||||
Text(viewModel.bookmarkDetail.title)
|
||||
.font(.largeTitle)
|
||||
.fontWeight(.bold)
|
||||
|
||||
// Meta-Informationen
|
||||
metaInfoSection
|
||||
|
||||
Divider()
|
||||
|
||||
// Artikel-Inhalt mit WebView
|
||||
if !viewModel.articleContent.isEmpty {
|
||||
WebView(htmlContent: viewModel.articleContent) { height in
|
||||
webViewHeight = height
|
||||
}
|
||||
.frame(height: webViewHeight)
|
||||
} else if viewModel.isLoadingArticle {
|
||||
ProgressView("Lade Artikel...")
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.task {
|
||||
await viewModel.loadBookmarkDetail(id: bookmarkId)
|
||||
await viewModel.loadArticleContent(id: bookmarkId)
|
||||
}
|
||||
}
|
||||
|
||||
private var metaInfoSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
if !viewModel.bookmarkDetail.authors.isEmpty {
|
||||
HStack {
|
||||
Image(systemName: "person")
|
||||
Text(viewModel.bookmarkDetail.authors.joined(separator: ", "))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Image(systemName: "calendar")
|
||||
Text("Erstellt: \(formatDate(viewModel.bookmarkDetail.created))")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Image(systemName: "textformat")
|
||||
Text("\(viewModel.bookmarkDetail.wordCount) Wörter • \(viewModel.bookmarkDetail.readingTime) min Lesezeit")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func formatDate(_ dateString: String) -> String {
|
||||
// Erstelle einen Formatter für das ISO8601-Format mit Millisekunden
|
||||
let isoFormatter = ISO8601DateFormatter()
|
||||
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
|
||||
// Fallback für Format ohne Millisekunden
|
||||
let isoFormatterNoMillis = ISO8601DateFormatter()
|
||||
isoFormatterNoMillis.formatOptions = [.withInternetDateTime]
|
||||
|
||||
// Versuche beide Formate
|
||||
var date: Date?
|
||||
if let parsedDate = isoFormatter.date(from: dateString) {
|
||||
date = parsedDate
|
||||
} else if let parsedDate = isoFormatterNoMillis.date(from: dateString) {
|
||||
date = parsedDate
|
||||
}
|
||||
|
||||
if let date = date {
|
||||
let displayFormatter = DateFormatter()
|
||||
displayFormatter.dateStyle = .medium
|
||||
displayFormatter.timeStyle = .short
|
||||
displayFormatter.locale = Locale(identifier: "de_DE")
|
||||
return displayFormatter.string(from: date)
|
||||
}
|
||||
|
||||
return dateString
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
NavigationView {
|
||||
BookmarkDetailView(bookmarkId: "sample-id")
|
||||
}
|
||||
}
|
||||
83
readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift
Normal file
83
readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift
Normal file
@ -0,0 +1,83 @@
|
||||
import Foundation
|
||||
|
||||
@Observable
|
||||
class BookmarkDetailViewModel {
|
||||
private let getBookmarkUseCase: GetBookmarkUseCase
|
||||
private let getBookmarkArticleUseCase: GetBookmarkArticleUseCase
|
||||
|
||||
var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty
|
||||
var articleContent: String = ""
|
||||
var articleParagraphs: [String] = []
|
||||
var bookmark: Bookmark? = nil
|
||||
var isLoading = false
|
||||
var isLoadingArticle = false
|
||||
var errorMessage: String?
|
||||
|
||||
init() {
|
||||
let factory = DefaultUseCaseFactory.shared
|
||||
self.getBookmarkUseCase = factory.makeGetBookmarkUseCase()
|
||||
self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func loadBookmarkDetail(id: String) async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
bookmarkDetail = try await getBookmarkUseCase.execute(id: id)
|
||||
|
||||
// Auch das vollständige Bookmark für readProgress laden
|
||||
// (Falls GetBookmarkUseCase nur BookmarkDetail zurückgibt)
|
||||
// Du könntest einen separaten UseCase für das vollständige Bookmark erstellen
|
||||
|
||||
} catch {
|
||||
errorMessage = "Fehler beim Laden des Bookmarks"
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func loadArticleContent(id: String) async {
|
||||
isLoadingArticle = true
|
||||
|
||||
do {
|
||||
articleContent = try await getBookmarkArticleUseCase.execute(id: id)
|
||||
processArticleContent()
|
||||
} catch {
|
||||
errorMessage = "Fehler beim Laden des Artikels"
|
||||
}
|
||||
|
||||
isLoadingArticle = false
|
||||
}
|
||||
|
||||
private func processArticleContent() {
|
||||
// HTML in Paragraphen aufteilen
|
||||
let paragraphs = articleContent
|
||||
.components(separatedBy: .newlines)
|
||||
.filter { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty }
|
||||
|
||||
articleParagraphs = paragraphs
|
||||
}
|
||||
}
|
||||
|
||||
extension BookmarkDetail {
|
||||
static let empty = BookmarkDetail(
|
||||
id: "",
|
||||
title: "",
|
||||
url: "",
|
||||
description: "",
|
||||
siteName: "",
|
||||
authors: [],
|
||||
created: "",
|
||||
updated: "",
|
||||
wordCount: 0,
|
||||
readingTime: 0,
|
||||
hasArticle: false,
|
||||
isMarked: false,
|
||||
isArchived: false,
|
||||
thumbnailUrl: "",
|
||||
imageUrl: ""
|
||||
)
|
||||
}
|
||||
112
readeck/UI/Bookmarks/BookmarkCardView.swift
Normal file
112
readeck/UI/Bookmarks/BookmarkCardView.swift
Normal file
@ -0,0 +1,112 @@
|
||||
import SwiftUI
|
||||
|
||||
struct BookmarkCardView: View {
|
||||
let bookmark: Bookmark
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
// Vorschaubild - verwende image oder thumbnail
|
||||
AsyncImage(url: imageURL) { image in
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
} placeholder: {
|
||||
Rectangle()
|
||||
.fill(Color.gray.opacity(0.2))
|
||||
.overlay {
|
||||
Image(systemName: "photo")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
}
|
||||
.frame(height: 120)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
// Status-Badges
|
||||
HStack {
|
||||
if bookmark.isMarked {
|
||||
Badge(text: "Markiert", color: .blue)
|
||||
}
|
||||
if bookmark.isArchived {
|
||||
Badge(text: "Archiviert", color: .gray)
|
||||
}
|
||||
if bookmark.hasArticle {
|
||||
Badge(text: "Artikel", color: .green)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
|
||||
// Titel
|
||||
Text(bookmark.title)
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
.lineLimit(2)
|
||||
.multilineTextAlignment(.leading)
|
||||
|
||||
// Beschreibung
|
||||
if !bookmark.description.isEmpty {
|
||||
Text(bookmark.description)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(3)
|
||||
.multilineTextAlignment(.leading)
|
||||
}
|
||||
|
||||
// Meta-Info
|
||||
HStack {
|
||||
if !bookmark.siteName.isEmpty {
|
||||
Label(bookmark.siteName, systemImage: "globe")
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
if let readingTime = bookmark.readingTime, readingTime > 0 {
|
||||
Label("\(readingTime) min", systemImage: "clock")
|
||||
}
|
||||
}
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
// Progress Bar für Lesefortschritt
|
||||
if bookmark.readProgress > 0 {
|
||||
ProgressView(value: Double(bookmark.readProgress), total: 100)
|
||||
.progressViewStyle(LinearProgressViewStyle())
|
||||
.frame(height: 4)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 12)
|
||||
}
|
||||
.background(Color(.systemBackground))
|
||||
.clipShape(RoundedRectangle(cornerRadius: 12))
|
||||
.shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1)
|
||||
}
|
||||
|
||||
private var imageURL: URL? {
|
||||
// Bevorzuge image, dann thumbnail, dann icon
|
||||
if let imageUrl = bookmark.resources.image?.src {
|
||||
return URL(string: imageUrl)
|
||||
} else if let thumbnailUrl = bookmark.resources.thumbnail?.src {
|
||||
return URL(string: thumbnailUrl)
|
||||
} else if let iconUrl = bookmark.resources.icon?.src {
|
||||
return URL(string: iconUrl)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
struct Badge: View {
|
||||
let text: String
|
||||
let color: Color
|
||||
|
||||
var body: some View {
|
||||
Text(text)
|
||||
.font(.caption2)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(color.opacity(0.2))
|
||||
.foregroundColor(color)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,15 +2,19 @@ import SwiftUI
|
||||
|
||||
struct BookmarksView: View {
|
||||
@State private var viewModel = BookmarksViewModel()
|
||||
let state: BookmarkState
|
||||
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
ZStack {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
} else {
|
||||
|
||||
List(viewModel.bookmarks, id: \.id) { bookmark in
|
||||
BookmarkRow(bookmark: bookmark)
|
||||
NavigationLink(destination: BookmarkDetailView(bookmarkId: bookmark.id)) {
|
||||
BookmarkCardView(bookmark: bookmark)
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await viewModel.loadBookmarks()
|
||||
@ -26,14 +30,14 @@ struct BookmarksView: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Meine Bookmarks")
|
||||
.navigationTitle(state.displayName)
|
||||
.alert("Fehler", isPresented: .constant(viewModel.errorMessage != nil)) {
|
||||
Button("OK", role: .cancel) { }
|
||||
} message: {
|
||||
Text(viewModel.errorMessage ?? "")
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadBookmarks()
|
||||
await viewModel.loadBookmarks(state: state)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -44,18 +48,20 @@ private struct BookmarkRow: View {
|
||||
let bookmark: Bookmark
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(bookmark.title)
|
||||
.font(.headline)
|
||||
|
||||
Text(bookmark.url)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(bookmark.createdAt)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
NavigationLink(destination: BookmarkDetailView(bookmarkId: bookmark.id)) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(bookmark.title)
|
||||
.font(.headline)
|
||||
|
||||
Text(bookmark.url)
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Text(bookmark.created)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,27 +2,36 @@ import Foundation
|
||||
|
||||
@Observable
|
||||
class BookmarksViewModel {
|
||||
private let getBooksmarksUseCase = DefaultUseCaseFactory.shared.makeGetBooksmarksUseCase()
|
||||
private let getBooksmarksUseCase = DefaultUseCaseFactory.shared.makeGetBookmarksUseCase()
|
||||
|
||||
var bookmarks: [Bookmark] = []
|
||||
var isLoading = false
|
||||
var errorMessage: String?
|
||||
var currentState: BookmarkState = .unread
|
||||
|
||||
|
||||
init() {
|
||||
|
||||
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func loadBookmarks() async {
|
||||
func loadBookmarks(state: BookmarkState = .unread) async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
|
||||
currentState = state
|
||||
|
||||
do {
|
||||
bookmarks = try await getBooksmarksUseCase.execute()
|
||||
bookmarks = try await getBooksmarksUseCase.execute(state: state)
|
||||
} catch {
|
||||
errorMessage = "Fehler beim Laden der Bookmarks"
|
||||
bookmarks = []
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func refreshBookmarks() async {
|
||||
await loadBookmarks(state: currentState)
|
||||
}
|
||||
}
|
||||
|
||||
18
readeck/UI/Components/StatView.swift
Normal file
18
readeck/UI/Components/StatView.swift
Normal file
@ -0,0 +1,18 @@
|
||||
import SwiftUI
|
||||
|
||||
struct StatView: View {
|
||||
let title: String
|
||||
let value: String
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 4) {
|
||||
Text(value)
|
||||
.font(.headline)
|
||||
.fontWeight(.semibold)
|
||||
Text(title)
|
||||
.font(.caption2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
135
readeck/UI/Components/WebView.swift
Normal file
135
readeck/UI/Components/WebView.swift
Normal file
@ -0,0 +1,135 @@
|
||||
import SwiftUI
|
||||
import WebKit
|
||||
|
||||
struct WebView: UIViewRepresentable {
|
||||
let htmlContent: String
|
||||
let onHeightChange: (CGFloat) -> Void
|
||||
|
||||
func makeUIView(context: Context) -> WKWebView {
|
||||
let webView = WKWebView()
|
||||
webView.navigationDelegate = context.coordinator
|
||||
webView.scrollView.isScrollEnabled = false
|
||||
|
||||
// Message Handler hier einmalig hinzufügen
|
||||
webView.configuration.userContentController.add(context.coordinator, name: "heightUpdate")
|
||||
context.coordinator.onHeightChange = onHeightChange
|
||||
|
||||
return webView
|
||||
}
|
||||
|
||||
func updateUIView(_ webView: WKWebView, context: Context) {
|
||||
// Nur den HTML-Inhalt laden, keine Handler-Konfiguration
|
||||
context.coordinator.onHeightChange = onHeightChange
|
||||
|
||||
let styledHTML = """
|
||||
<html>
|
||||
<head>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<style>
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
line-height: 1.8;
|
||||
margin: 0;
|
||||
padding: 16px;
|
||||
color: #1a1a1a;
|
||||
font-size: 16px;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
color: #000;
|
||||
margin-top: 24px;
|
||||
margin-bottom: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
h1 { font-size: 24px; }
|
||||
h2 { font-size: 20px; }
|
||||
h3 { font-size: 18px; }
|
||||
p {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
border-radius: 8px;
|
||||
margin: 16px 0;
|
||||
}
|
||||
a {
|
||||
color: #007AFF;
|
||||
text-decoration: none;
|
||||
}
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
blockquote {
|
||||
border-left: 4px solid #007AFF;
|
||||
margin: 16px 0;
|
||||
padding-left: 16px;
|
||||
font-style: italic;
|
||||
color: #666;
|
||||
}
|
||||
code {
|
||||
background-color: #f5f5f5;
|
||||
padding: 2px 4px;
|
||||
border-radius: 4px;
|
||||
font-family: 'SF Mono', Consolas, monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
pre {
|
||||
background-color: #f5f5f5;
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
overflow-x: auto;
|
||||
font-family: 'SF Mono', Consolas, monospace;
|
||||
font-size: 14px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
\(htmlContent)
|
||||
<script>
|
||||
function updateHeight() {
|
||||
const height = document.body.scrollHeight;
|
||||
window.webkit.messageHandlers.heightUpdate.postMessage(height);
|
||||
}
|
||||
|
||||
window.addEventListener('load', updateHeight);
|
||||
setTimeout(updateHeight, 100);
|
||||
setTimeout(updateHeight, 500);
|
||||
setTimeout(updateHeight, 1000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
webView.loadHTMLString(styledHTML, baseURL: nil)
|
||||
}
|
||||
|
||||
func makeCoordinator() -> WebViewCoordinator {
|
||||
WebViewCoordinator()
|
||||
}
|
||||
}
|
||||
|
||||
class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
|
||||
var onHeightChange: ((CGFloat) -> Void)?
|
||||
|
||||
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
||||
if navigationAction.navigationType == .linkActivated {
|
||||
if let url = navigationAction.request.url {
|
||||
UIApplication.shared.open(url)
|
||||
decisionHandler(.cancel)
|
||||
return
|
||||
}
|
||||
}
|
||||
decisionHandler(.allow)
|
||||
}
|
||||
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
if message.name == "heightUpdate", let height = message.body as? CGFloat {
|
||||
DispatchQueue.main.async {
|
||||
self.onHeightChange?(height)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
deinit {
|
||||
// Der Message Handler wird automatisch mit der WebView entfernt
|
||||
}
|
||||
}
|
||||
@ -6,47 +6,84 @@ struct SettingsView: View {
|
||||
var body: some View {
|
||||
NavigationView {
|
||||
Form {
|
||||
Section(header: Text("Anmeldedaten"), footer: SectionFooter()) {
|
||||
Section("Server-Einstellungen") {
|
||||
TextField("Endpoint URL", text: $viewModel.endpoint)
|
||||
.textContentType(.URL)
|
||||
.keyboardType(.URL)
|
||||
.autocapitalization(.none)
|
||||
|
||||
TextField("Benutzername", text: $viewModel.username)
|
||||
.autocorrectionDisabled()
|
||||
.textInputAutocapitalization(.never)
|
||||
.textContentType(.username)
|
||||
.autocapitalization(.none)
|
||||
|
||||
SecureField("Passwort", text: $viewModel.password)
|
||||
|
||||
TextField("Endpoint", text: $viewModel.endpoint)
|
||||
.keyboardType(.URL)
|
||||
|
||||
.textContentType(.password)
|
||||
}
|
||||
|
||||
Section {
|
||||
Button {
|
||||
Task {
|
||||
await viewModel.saveSettings()
|
||||
}
|
||||
} label: {
|
||||
HStack {
|
||||
if viewModel.isSaving {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
}
|
||||
Text("Einstellungen speichern")
|
||||
}
|
||||
}
|
||||
.disabled(!viewModel.canSave || viewModel.isSaving)
|
||||
}
|
||||
|
||||
Section("Anmeldung") {
|
||||
Button {
|
||||
Task {
|
||||
await viewModel.login()
|
||||
}
|
||||
} label: {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
} else {
|
||||
Text("Speichern")
|
||||
HStack {
|
||||
if viewModel.isLoading {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
}
|
||||
Text(viewModel.isLoggedIn ? "Erneut anmelden" : "Anmelden")
|
||||
}
|
||||
}
|
||||
.disabled(!viewModel.canLogin || viewModel.isLoading)
|
||||
|
||||
if viewModel.isLoggedIn {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text("Erfolgreich angemeldet")
|
||||
}
|
||||
}
|
||||
.disabled(viewModel.isLoginDisabled)
|
||||
}
|
||||
|
||||
|
||||
// Success/Error Messages
|
||||
if let successMessage = viewModel.successMessage {
|
||||
Section {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text(successMessage)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.navigationTitle("Einstellungen")
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func SectionFooter() -> some View {
|
||||
switch viewModel.state {
|
||||
case .error:
|
||||
Text("Anmeldung fehlgeschlagen. Bitte überprüfen Sie Ihre Eingaben.")
|
||||
.foregroundColor(.red)
|
||||
case .success:
|
||||
Text("Anmeldung erfolgreich!")
|
||||
.foregroundColor(.green)
|
||||
case .default:
|
||||
Text("")
|
||||
.alert("Fehler", isPresented: .constant(viewModel.errorMessage != nil)) {
|
||||
Button("OK", role: .cancel) {
|
||||
viewModel.errorMessage = nil
|
||||
}
|
||||
} message: {
|
||||
Text(viewModel.errorMessage ?? "")
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadSettings()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,36 +2,103 @@ import Foundation
|
||||
|
||||
@Observable
|
||||
class SettingsViewModel {
|
||||
private let loginUseCase: LoginUseCase
|
||||
private let saveSettingsUseCase: SaveSettingsUseCase
|
||||
private let loadSettingsUseCase: LoadSettingsUseCase
|
||||
|
||||
enum State {
|
||||
case `default`, error, success
|
||||
var endpoint = ""
|
||||
var username = ""
|
||||
var password = ""
|
||||
var isLoading = false
|
||||
var isSaving = false
|
||||
var isLoggedIn = false
|
||||
var errorMessage: String?
|
||||
var successMessage: String?
|
||||
|
||||
init() {
|
||||
let factory = DefaultUseCaseFactory.shared
|
||||
self.loginUseCase = factory.makeLoginUseCase()
|
||||
self.saveSettingsUseCase = factory.makeSaveSettingsUseCase()
|
||||
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
|
||||
}
|
||||
|
||||
var username: String = "admin"
|
||||
var password: String = "Diggah123"
|
||||
var endpoint: String = ""
|
||||
var isLoading: Bool = false
|
||||
var state: State = .default
|
||||
var showAlert: Bool = false
|
||||
@MainActor
|
||||
func loadSettings() async {
|
||||
do {
|
||||
if let settings = try await loadSettingsUseCase.execute() {
|
||||
endpoint = settings.endpoint
|
||||
username = settings.username
|
||||
password = settings.password
|
||||
isLoggedIn = settings.isLoggedIn // Verwendet die neue Hilfsmethode
|
||||
}
|
||||
} catch {
|
||||
errorMessage = "Fehler beim Laden der Einstellungen"
|
||||
}
|
||||
}
|
||||
|
||||
private let loginUseCase = DefaultUseCaseFactory.shared.makeLoginUseCase()
|
||||
|
||||
var isLoginDisabled: Bool {
|
||||
username.isEmpty || password.isEmpty || isLoading
|
||||
@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 userResult = try await loginUseCase.execute(username: username, password: password)
|
||||
state = userResult.token.isEmpty ? .error : .success
|
||||
isLoading = false
|
||||
_ = try await loginUseCase.execute(username: username, password: password)
|
||||
isLoggedIn = true
|
||||
successMessage = "Erfolgreich angemeldet"
|
||||
|
||||
// Factory-Konfiguration aktualisieren (Token wird automatisch gespeichert)
|
||||
await DefaultUseCaseFactory.shared.refreshConfiguration()
|
||||
|
||||
} catch {
|
||||
state = .error
|
||||
isLoading = false
|
||||
errorMessage = "Anmeldung fehlgeschlagen"
|
||||
isLoggedIn = false
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func logout() async {
|
||||
do {
|
||||
// Hier könntest du eine Logout-UseCase hinzufügen
|
||||
// try await logoutUseCase.execute()
|
||||
isLoggedIn = false
|
||||
successMessage = "Abgemeldet"
|
||||
} catch {
|
||||
errorMessage = "Fehler beim Abmelden"
|
||||
}
|
||||
}
|
||||
|
||||
var canSave: Bool {
|
||||
!endpoint.isEmpty && !username.isEmpty && !password.isEmpty
|
||||
}
|
||||
|
||||
var canLogin: Bool {
|
||||
!username.isEmpty && !password.isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,17 +1,57 @@
|
||||
import SwiftUI
|
||||
import Foundation
|
||||
|
||||
enum BookmarkState: String, CaseIterable {
|
||||
case unread = "unread"
|
||||
case favorite = "favorite"
|
||||
case archived = "archived"
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .unread:
|
||||
return "Ungelesen"
|
||||
case .favorite:
|
||||
return "Favoriten"
|
||||
case .archived:
|
||||
return "Archiv"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .unread:
|
||||
return "house"
|
||||
case .favorite:
|
||||
return "heart"
|
||||
case .archived:
|
||||
return "archivebox"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MainTabView: View {
|
||||
@State private var selectedTab: String = "Home"
|
||||
@State private var selectedTab: String = "Ungelesen"
|
||||
|
||||
var body: some View {
|
||||
|
||||
TabView() {
|
||||
BookmarksView()
|
||||
TabView(selection: $selectedTab) {
|
||||
BookmarksView(state: .unread)
|
||||
.tabItem {
|
||||
Label("Links", systemImage: "house")
|
||||
Label("Ungelesen", systemImage: "house")
|
||||
}
|
||||
.tag("Home")
|
||||
.tag("Ungelesen")
|
||||
|
||||
BookmarksView(state: .favorite)
|
||||
.tabItem {
|
||||
Label("Favoriten", systemImage: "heart")
|
||||
}
|
||||
.tag("Favoriten")
|
||||
|
||||
BookmarksView(state: .archived)
|
||||
.tabItem {
|
||||
Label("Archiv", systemImage: "archivebox")
|
||||
}
|
||||
.tag("Archiv")
|
||||
|
||||
SettingsView()
|
||||
.tabItem {
|
||||
Label("Settings", systemImage: "gear")
|
||||
@ -21,3 +61,7 @@ struct MainTabView: View {
|
||||
.accentColor(.blue)
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
MainTabView()
|
||||
}
|
||||
|
||||
@ -13,7 +13,6 @@ struct readeckApp: App {
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
// ContentView()
|
||||
MainTabView()
|
||||
.environment(\.managedObjectContext, persistenceController.container.viewContext)
|
||||
}
|
||||
|
||||
@ -1,9 +1,12 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="1" systemVersion="11A491" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="false" userDefinedModelVersionIdentifier="">
|
||||
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23507" systemVersion="24B83" minimumToolsVersion="Automatic" sourceLanguage="Swift" userDefinedModelVersionIdentifier="">
|
||||
<entity name="Item" representedClassName="Item" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="timestamp" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
|
||||
</entity>
|
||||
<elements>
|
||||
<element name="Item" positionX="-63" positionY="-18" width="128" height="44"/>
|
||||
</elements>
|
||||
<entity name="SettingEntity" representedClassName="SettingEntity" syncable="YES" codeGenerationType="class">
|
||||
<attribute name="endpoint" optional="YES" attributeType="String"/>
|
||||
<attribute name="password" optional="YES" attributeType="String"/>
|
||||
<attribute name="token" optional="YES" attributeType="String"/>
|
||||
<attribute name="username" optional="YES" attributeType="String"/>
|
||||
</entity>
|
||||
</model>
|
||||
Loading…
x
Reference in New Issue
Block a user