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:
Ilyas Hallak 2025-06-11 22:02:44 +02:00
parent 98a914cb2e
commit c8368f0a70
35 changed files with 1483 additions and 306 deletions

View File

@ -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)
}

View File

@ -0,0 +1,4 @@
import Foundation
// BookmarkDetailDto ist identisch mit BookmarkDto
typealias BookmarkDetailDto = BookmarkDto

View 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
}

View File

@ -0,0 +1,7 @@
import Foundation
struct LoginRequestDto: Codable {
let application: String
let username: String
let password: String
}

View 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)")
}
}
}
}

View File

@ -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"
}
}
}

View File

@ -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"
}
}

View 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)
}
}

View File

@ -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 {

View File

@ -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
}

View 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)
}
}
}
}
}

View 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)")
}
}
}

View 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)")
}
}
}

View File

@ -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
}
}

View 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
}

View File

@ -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
}

View 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)
}
}

View 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)
}
}

View File

@ -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
}
}

View 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()
}
}

View 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)
}
}

View 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")
}
}

View 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: ""
)
}

View 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())
}
}

View File

@ -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)
}
}

View File

@ -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)
}
}

View 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)
}
}

View 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
}
}

View File

@ -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()
}
}
}
}

View File

@ -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
}
}

View File

@ -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()
}

View File

@ -13,7 +13,6 @@ struct readeckApp: App {
var body: some Scene {
WindowGroup {
// ContentView()
MainTabView()
.environment(\.managedObjectContext, persistenceController.container.viewContext)
}

View File

@ -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>