feat: introduce protocol-based UseCase architecture and mock factory

- Add protocols for all UseCases and implement them in their respective classes
- Add DefaultUseCaseFactory and MockUseCaseFactory for dependency injection
- Implement all mock UseCases with dummy data
- Start migration of view models and views to protocol-based UseCase injection (not all migrated yet)
- Refactor previews and some initializers for easier testing
- Move SectionHeader to Components, update server settings UI text
- Add sample article.html for mock content
This commit is contained in:
Ilyas Hallak 2025-07-18 00:46:07 +02:00
parent 7861368196
commit 930779169b
32 changed files with 597 additions and 172 deletions

View File

@ -79,9 +79,6 @@
},
"Automatischer Sync" : {
},
"Benutzername" : {
},
"Bookmark archivieren" : {
@ -163,12 +160,6 @@
},
"https://readeck.example.com" : {
},
"Ihr Benutzername" : {
},
"Ihr Passwort" : {
},
"Ihre aktuelle Server-Verbindung und Anmeldedaten." : {
@ -240,7 +231,7 @@
"Optional: Eigener Titel" : {
},
"Passwort" : {
"Password" : {
},
"readeck Bookmark Title" : {
@ -305,6 +296,9 @@
},
"URL gefunden:" : {
},
"Username" : {
},
"Version %@" : {
@ -326,6 +320,12 @@
},
"Wird gespeichert..." : {
},
"Your Password" : {
},
"Your Username" : {
},
"z.B. arbeit, wichtig, später" : {

View File

@ -1,6 +1,11 @@
import Foundation
class AddLabelsToBookmarkUseCase {
protocol PAddLabelsToBookmarkUseCase {
func execute(bookmarkId: String, labels: [String]) async throws
func execute(bookmarkId: String, label: String) async throws
}
class AddLabelsToBookmarkUseCase: PAddLabelsToBookmarkUseCase {
private let repository: PBookmarksRepository
init(repository: PBookmarksRepository) {

View File

@ -1,6 +1,10 @@
import Foundation
class AddTextToSpeechQueueUseCase {
protocol PAddTextToSpeechQueueUseCase {
func execute(bookmarkDetail: BookmarkDetail)
}
class AddTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase {
private let speechQueue: SpeechQueue
init(speechQueue: SpeechQueue = .shared) {

View File

@ -1,6 +1,14 @@
import Foundation
class CreateBookmarkUseCase {
protocol PCreateBookmarkUseCase {
func execute(createRequest: CreateBookmarkRequest) async throws -> String
func createFromURL(_ url: String) async throws -> String
func createFromURLWithTitle(_ url: String, title: String) async throws -> String
func createFromURLWithLabels(_ url: String, labels: [String]) async throws -> String
func createFromClipboard() async throws -> String?
}
class CreateBookmarkUseCase: PCreateBookmarkUseCase {
private let repository: PBookmarksRepository
init(repository: PBookmarksRepository) {

View File

@ -1,6 +1,10 @@
import Foundation
class DeleteBookmarkUseCase {
protocol PDeleteBookmarkUseCase {
func execute(bookmarkId: String) async throws
}
class DeleteBookmarkUseCase: PDeleteBookmarkUseCase {
private let repository: PBookmarksRepository
init(repository: PBookmarksRepository) {

View File

@ -1,6 +1,10 @@
import Foundation
class GetBookmarkArticleUseCase {
protocol PGetBookmarkArticleUseCase {
func execute(id: String) async throws -> String
}
class GetBookmarkArticleUseCase: PGetBookmarkArticleUseCase {
private let repository: PBookmarksRepository
init(repository: PBookmarksRepository) {

View File

@ -1,6 +1,10 @@
import Foundation
class GetBookmarkUseCase {
protocol PGetBookmarkUseCase {
func execute(id: String) async throws -> BookmarkDetail
}
class GetBookmarkUseCase: PGetBookmarkUseCase {
private let repository: PBookmarksRepository
init(repository: PBookmarksRepository) {

View File

@ -1,6 +1,10 @@
import Foundation
class GetBookmarksUseCase {
protocol PGetBookmarksUseCase {
func execute(state: BookmarkState?, limit: Int?, offset: Int?, search: String?, type: [BookmarkType]?, tag: String?) async throws -> BookmarksPage
}
class GetBookmarksUseCase: PGetBookmarksUseCase {
private let repository: PBookmarksRepository
init(repository: PBookmarksRepository) {

View File

@ -1,6 +1,10 @@
import Foundation
class GetLabelsUseCase {
protocol PGetLabelsUseCase {
func execute() async throws -> [BookmarkLabel]
}
class GetLabelsUseCase: PGetLabelsUseCase {
private let labelsRepository: PLabelsRepository
init(labelsRepository: PLabelsRepository) {

View File

@ -1,6 +1,10 @@
import Foundation
class LoadSettingsUseCase {
protocol PLoadSettingsUseCase {
func execute() async throws -> Settings?
}
class LoadSettingsUseCase: PLoadSettingsUseCase {
private let authRepository: PAuthRepository
init(authRepository: PAuthRepository) {

View File

@ -1,4 +1,9 @@
class LoginUseCase {
protocol PLoginUseCase {
func execute(endpoint: String, username: String, password: String) async throws -> User
}
class LoginUseCase: PLoginUseCase {
private let repository: PAuthRepository
init(repository: PAuthRepository) {

View File

@ -7,11 +7,11 @@
import Foundation
protocol LogoutUseCaseProtocol {
protocol PLogoutUseCase {
func execute() async throws
}
class LogoutUseCase: LogoutUseCaseProtocol {
class LogoutUseCase: PLogoutUseCase {
private let settingsRepository: SettingsRepository
private let tokenManager: TokenManager

View File

@ -1,6 +1,10 @@
import Foundation
class ReadBookmarkUseCase {
protocol PReadBookmarkUseCase {
func execute(bookmarkDetail: BookmarkDetail)
}
class ReadBookmarkUseCase: PReadBookmarkUseCase {
private let addToSpeechQueue: AddTextToSpeechQueueUseCase
init(addToSpeechQueue: AddTextToSpeechQueueUseCase = AddTextToSpeechQueueUseCase()) {

View File

@ -1,6 +1,11 @@
import Foundation
class RemoveLabelsFromBookmarkUseCase {
protocol PRemoveLabelsFromBookmarkUseCase {
func execute(bookmarkId: String, labels: [String]) async throws
func execute(bookmarkId: String, label: String) async throws
}
class RemoveLabelsFromBookmarkUseCase: PRemoveLabelsFromBookmarkUseCase {
private let repository: PBookmarksRepository
init(repository: PBookmarksRepository) {

View File

@ -1,6 +1,10 @@
import Foundation
class SaveServerSettingsUseCase {
protocol PSaveServerSettingsUseCase {
func execute(endpoint: String, username: String, password: String, token: String) async throws
}
class SaveServerSettingsUseCase: PSaveServerSettingsUseCase {
private let repository: PSettingsRepository
init(repository: PSettingsRepository) {

View File

@ -1,6 +1,13 @@
import Foundation
class SaveSettingsUseCase {
protocol PSaveSettingsUseCase {
func execute(endpoint: String, username: String, password: String) async throws
func execute(endpoint: String, username: String, password: String, hasFinishedSetup: Bool) async throws
func execute(token: String) async throws
func execute(selectedFontFamily: FontFamily, selectedFontSize: FontSize) async throws
}
class SaveSettingsUseCase: PSaveSettingsUseCase {
private let settingsRepository: PSettingsRepository
init(settingsRepository: PSettingsRepository) {

View File

@ -1,6 +1,10 @@
import Foundation
class SearchBookmarksUseCase {
protocol PSearchBookmarksUseCase {
func execute(search: String) async throws -> BookmarksPage
}
class SearchBookmarksUseCase: PSearchBookmarksUseCase {
private let repository: PBookmarksRepository
init(repository: PBookmarksRepository) {

View File

@ -1,6 +1,18 @@
import Foundation
class UpdateBookmarkUseCase {
protocol PUpdateBookmarkUseCase {
func execute(bookmarkId: String, updateRequest: BookmarkUpdateRequest) async throws
func toggleArchive(bookmarkId: String, isArchived: Bool) async throws
func toggleFavorite(bookmarkId: String, isMarked: Bool) async throws
func markAsDeleted(bookmarkId: String) async throws
func updateReadProgress(bookmarkId: String, progress: Int, anchor: String?) async throws
func updateTitle(bookmarkId: String, title: String) async throws
func updateLabels(bookmarkId: String, labels: [String]) async throws
func addLabels(bookmarkId: String, labels: [String]) async throws
func removeLabels(bookmarkId: String, labels: [String]) async throws
}
class UpdateBookmarkUseCase: PUpdateBookmarkUseCase {
private let repository: PBookmarksRepository
init(repository: PBookmarksRepository) {

View File

@ -3,7 +3,7 @@ import SafariServices
struct BookmarkDetailView: View {
let bookmarkId: String
@State private var viewModel = BookmarkDetailViewModel()
@State private var viewModel: BookmarkDetailViewModel
@State private var webViewHeight: CGFloat = 300
@State private var showingFontSettings = false
@State private var showingLabelsSheet = false
@ -11,6 +11,14 @@ struct BookmarkDetailView: View {
private let headerHeight: CGFloat = 320
init(bookmarkId: String, viewModel: BookmarkDetailViewModel = BookmarkDetailViewModel(), webViewHeight: CGFloat = 300, showingFontSettings: Bool = false, showingLabelsSheet: Bool = false, playerUIState: PlayerUIState = .init()) {
self.bookmarkId = bookmarkId
self.viewModel = viewModel
self.webViewHeight = webViewHeight
self.showingFontSettings = showingFontSettings
self.showingLabelsSheet = showingLabelsSheet
}
var body: some View {
GeometryReader { geometry in
ScrollView {
@ -156,7 +164,7 @@ struct BookmarkDetailView: View {
private var contentSection: some View {
if let settings = viewModel.settings, !viewModel.articleContent.isEmpty {
WebView(htmlContent: viewModel.articleContent, settings: settings) { height in
withAnimation(.easeInOut(duration: 0.3)) {
withAnimation(.easeInOut(duration: 0.1)) {
webViewHeight = height
}
}
@ -181,7 +189,7 @@ struct BookmarkDetailView: View {
}
.buttonStyle(.borderedProminent)
.padding(.horizontal)
.padding(.top, 4)
.padding(.top, 0)
}
}
@ -339,6 +347,11 @@ struct BookmarkDetailView: View {
#Preview {
NavigationView {
BookmarkDetailView(bookmarkId: "sample-id")
BookmarkDetailView(bookmarkId: "123",
viewModel: .init(MockUseCaseFactory()),
webViewHeight: 300,
showingFontSettings: false,
showingLabelsSheet: false,
playerUIState: .init())
}
}

View File

@ -2,11 +2,11 @@ import Foundation
@Observable
class BookmarkDetailViewModel {
private let getBookmarkUseCase: GetBookmarkUseCase
private let getBookmarkArticleUseCase: GetBookmarkArticleUseCase
private let loadSettingsUseCase: LoadSettingsUseCase
private let updateBookmarkUseCase: UpdateBookmarkUseCase
private let addTextToSpeechQueueUseCase: AddTextToSpeechQueueUseCase
private let getBookmarkUseCase: PGetBookmarkUseCase
private let getBookmarkArticleUseCase: PGetBookmarkArticleUseCase
private let loadSettingsUseCase: PLoadSettingsUseCase
private let updateBookmarkUseCase: PUpdateBookmarkUseCase
private let addTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase
var bookmarkDetail: BookmarkDetail = BookmarkDetail.empty
var articleContent: String = ""
@ -17,8 +17,7 @@ class BookmarkDetailViewModel {
var errorMessage: String?
var settings: Settings?
init() {
let factory = DefaultUseCaseFactory.shared
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
self.getBookmarkUseCase = factory.makeGetBookmarkUseCase()
self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase()
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()

View File

@ -4,9 +4,9 @@ import SwiftUI
@Observable
class BookmarksViewModel {
private let getBooksmarksUseCase = DefaultUseCaseFactory.shared.makeGetBookmarksUseCase()
private let updateBookmarkUseCase = DefaultUseCaseFactory.shared.makeUpdateBookmarkUseCase()
private let deleteBookmarkUseCase = DefaultUseCaseFactory.shared.makeDeleteBookmarkUseCase()
private let getBooksmarksUseCase: PGetBookmarksUseCase = DefaultUseCaseFactory.shared.makeGetBookmarksUseCase()
private let updateBookmarkUseCase: PUpdateBookmarkUseCase = DefaultUseCaseFactory.shared.makeUpdateBookmarkUseCase()
private let deleteBookmarkUseCase: PDeleteBookmarkUseCase = DefaultUseCaseFactory.shared.makeDeleteBookmarkUseCase()
var bookmarks: BookmarksPage?
var isLoading = false
@ -118,6 +118,7 @@ class BookmarksViewModel {
state: currentState,
limit: limit,
offset: offset,
search: nil,
type: currentType,
tag: currentTag)
bookmarks?.bookmarks.append(contentsOf: newBookmarks.bookmarks)

View File

@ -14,4 +14,8 @@ struct SectionHeader: View {
.fontWeight(.bold)
}
}
}
}
#Preview {
SectionHeader(title: "hello", icon: "person.circle")
}

View File

@ -1,103 +0,0 @@
import Foundation
protocol UseCaseFactory {
func makeLoginUseCase() -> LoginUseCase
func makeGetBookmarksUseCase() -> GetBookmarksUseCase
func makeGetBookmarkUseCase() -> GetBookmarkUseCase
func makeGetBookmarkArticleUseCase() -> GetBookmarkArticleUseCase
func makeSaveSettingsUseCase() -> SaveSettingsUseCase
func makeLoadSettingsUseCase() -> LoadSettingsUseCase
func makeUpdateBookmarkUseCase() -> UpdateBookmarkUseCase
func makeDeleteBookmarkUseCase() -> DeleteBookmarkUseCase
func makeCreateBookmarkUseCase() -> CreateBookmarkUseCase
func makeLogoutUseCase() -> LogoutUseCase
func makeSearchBookmarksUseCase() -> SearchBookmarksUseCase
func makeSaveServerSettingsUseCase() -> SaveServerSettingsUseCase
func makeAddLabelsToBookmarkUseCase() -> AddLabelsToBookmarkUseCase
func makeRemoveLabelsFromBookmarkUseCase() -> RemoveLabelsFromBookmarkUseCase
func makeGetLabelsUseCase() -> GetLabelsUseCase
func makeAddTextToSpeechQueueUseCase() -> AddTextToSpeechQueueUseCase
}
class DefaultUseCaseFactory: UseCaseFactory {
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)
private let settingsRepository: PSettingsRepository = SettingsRepository()
static let shared = DefaultUseCaseFactory()
private init() {}
func makeLoginUseCase() -> LoginUseCase {
LoginUseCase(repository: authRepository)
}
func makeGetBookmarksUseCase() -> GetBookmarksUseCase {
GetBookmarksUseCase(repository: bookmarksRepository)
}
func makeGetBookmarkUseCase() -> GetBookmarkUseCase {
GetBookmarkUseCase(repository: bookmarksRepository)
}
func makeGetBookmarkArticleUseCase() -> GetBookmarkArticleUseCase {
GetBookmarkArticleUseCase(repository: bookmarksRepository)
}
func makeSaveSettingsUseCase() -> SaveSettingsUseCase {
SaveSettingsUseCase(settingsRepository: settingsRepository)
}
func makeLoadSettingsUseCase() -> LoadSettingsUseCase {
LoadSettingsUseCase(authRepository: authRepository)
}
func makeUpdateBookmarkUseCase() -> UpdateBookmarkUseCase {
return UpdateBookmarkUseCase(repository: bookmarksRepository)
}
func makeLogoutUseCase() -> LogoutUseCase {
return LogoutUseCase()
}
// Nicht mehr nötig - Token wird automatisch geladen
func refreshConfiguration() async {
// Optional: Cache löschen falls nötig
}
func makeDeleteBookmarkUseCase() -> DeleteBookmarkUseCase {
return DeleteBookmarkUseCase(repository: bookmarksRepository)
}
func makeCreateBookmarkUseCase() -> CreateBookmarkUseCase {
return CreateBookmarkUseCase(repository: bookmarksRepository)
}
func makeSearchBookmarksUseCase() -> SearchBookmarksUseCase {
return SearchBookmarksUseCase(repository: bookmarksRepository)
}
func makeSaveServerSettingsUseCase() -> SaveServerSettingsUseCase {
return SaveServerSettingsUseCase(repository: SettingsRepository())
}
func makeAddLabelsToBookmarkUseCase() -> AddLabelsToBookmarkUseCase {
return AddLabelsToBookmarkUseCase(repository: bookmarksRepository)
}
func makeRemoveLabelsFromBookmarkUseCase() -> RemoveLabelsFromBookmarkUseCase {
return RemoveLabelsFromBookmarkUseCase(repository: bookmarksRepository)
}
func makeGetLabelsUseCase() -> GetLabelsUseCase {
let api = API(tokenProvider: CoreDataTokenProvider())
let labelsRepository = LabelsRepository(api: api)
return GetLabelsUseCase(labelsRepository: labelsRepository)
}
func makeAddTextToSpeechQueueUseCase() -> AddTextToSpeechQueueUseCase {
return AddTextToSpeechQueueUseCase()
}
}

View File

@ -0,0 +1,100 @@
import Foundation
protocol UseCaseFactory {
func makeLoginUseCase() -> PLoginUseCase
func makeGetBookmarksUseCase() -> PGetBookmarksUseCase
func makeGetBookmarkUseCase() -> PGetBookmarkUseCase
func makeGetBookmarkArticleUseCase() -> PGetBookmarkArticleUseCase
func makeSaveSettingsUseCase() -> PSaveSettingsUseCase
func makeLoadSettingsUseCase() -> PLoadSettingsUseCase
func makeUpdateBookmarkUseCase() -> PUpdateBookmarkUseCase
func makeDeleteBookmarkUseCase() -> PDeleteBookmarkUseCase
func makeCreateBookmarkUseCase() -> PCreateBookmarkUseCase
func makeLogoutUseCase() -> PLogoutUseCase
func makeSearchBookmarksUseCase() -> PSearchBookmarksUseCase
func makeSaveServerSettingsUseCase() -> PSaveServerSettingsUseCase
func makeAddLabelsToBookmarkUseCase() -> PAddLabelsToBookmarkUseCase
func makeRemoveLabelsFromBookmarkUseCase() -> PRemoveLabelsFromBookmarkUseCase
func makeGetLabelsUseCase() -> PGetLabelsUseCase
func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase
}
class DefaultUseCaseFactory: UseCaseFactory {
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)
private let settingsRepository: PSettingsRepository = SettingsRepository()
static let shared = DefaultUseCaseFactory()
private init() {}
func makeLoginUseCase() -> PLoginUseCase {
LoginUseCase(repository: authRepository)
}
func makeGetBookmarksUseCase() -> PGetBookmarksUseCase {
GetBookmarksUseCase(repository: bookmarksRepository)
}
func makeGetBookmarkUseCase() -> PGetBookmarkUseCase {
GetBookmarkUseCase(repository: bookmarksRepository)
}
func makeGetBookmarkArticleUseCase() -> PGetBookmarkArticleUseCase {
GetBookmarkArticleUseCase(repository: bookmarksRepository)
}
func makeSaveSettingsUseCase() -> PSaveSettingsUseCase {
SaveSettingsUseCase(settingsRepository: settingsRepository)
}
func makeLoadSettingsUseCase() -> PLoadSettingsUseCase {
LoadSettingsUseCase(authRepository: authRepository)
}
func makeUpdateBookmarkUseCase() -> PUpdateBookmarkUseCase {
return UpdateBookmarkUseCase(repository: bookmarksRepository)
}
func makeLogoutUseCase() -> PLogoutUseCase {
return LogoutUseCase()
}
func makeDeleteBookmarkUseCase() -> PDeleteBookmarkUseCase {
return DeleteBookmarkUseCase(repository: bookmarksRepository)
}
func makeCreateBookmarkUseCase() -> PCreateBookmarkUseCase {
return CreateBookmarkUseCase(repository: bookmarksRepository)
}
func makeSearchBookmarksUseCase() -> PSearchBookmarksUseCase {
return SearchBookmarksUseCase(repository: bookmarksRepository)
}
func makeSaveServerSettingsUseCase() -> PSaveServerSettingsUseCase {
return SaveServerSettingsUseCase(repository: SettingsRepository())
}
func makeAddLabelsToBookmarkUseCase() -> PAddLabelsToBookmarkUseCase {
return AddLabelsToBookmarkUseCase(repository: bookmarksRepository)
}
func makeRemoveLabelsFromBookmarkUseCase() -> PRemoveLabelsFromBookmarkUseCase {
return RemoveLabelsFromBookmarkUseCase(repository: bookmarksRepository)
}
func makeGetLabelsUseCase() -> PGetLabelsUseCase {
let api = API(tokenProvider: CoreDataTokenProvider())
let labelsRepository = LabelsRepository(api: api)
return GetLabelsUseCase(labelsRepository: labelsRepository)
}
func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase {
return AddTextToSpeechQueueUseCase()
}
}

View File

@ -0,0 +1,178 @@
//
// MockUseCaseFactory.swift
// readeck
//
// Created by Ilyas Hallak on 18.07.25.
//
import Foundation
class MockUseCaseFactory: UseCaseFactory {
func makeLoginUseCase() -> any PLoginUseCase {
MockLoginUserCase()
}
func makeGetBookmarksUseCase() -> any PGetBookmarksUseCase {
MockGetBookmarksUseCase()
}
func makeGetBookmarkUseCase() -> any PGetBookmarkUseCase {
MockGetBookmarkUseCase()
}
func makeGetBookmarkArticleUseCase() -> any PGetBookmarkArticleUseCase {
MockGetBookmarkArticleUseCase()
}
func makeSaveSettingsUseCase() -> any PSaveSettingsUseCase {
MockSaveSettingsUseCase()
}
func makeLoadSettingsUseCase() -> any PLoadSettingsUseCase {
MockLoadSettingsUseCase()
}
func makeUpdateBookmarkUseCase() -> any PUpdateBookmarkUseCase {
MockUpdateBookmarkUseCase()
}
func makeDeleteBookmarkUseCase() -> any PDeleteBookmarkUseCase {
MockDeleteBookmarkUseCase()
}
func makeCreateBookmarkUseCase() -> any PCreateBookmarkUseCase {
MockCreateBookmarkUseCase()
}
func makeLogoutUseCase() -> any PLogoutUseCase {
MockLogoutUseCase()
}
func makeSearchBookmarksUseCase() -> any PSearchBookmarksUseCase {
MockSearchBookmarksUseCase()
}
func makeSaveServerSettingsUseCase() -> any PSaveServerSettingsUseCase {
MockSaveServerSettingsUseCase()
}
func makeAddLabelsToBookmarkUseCase() -> any PAddLabelsToBookmarkUseCase {
MockAddLabelsToBookmarkUseCase()
}
func makeRemoveLabelsFromBookmarkUseCase() -> any PRemoveLabelsFromBookmarkUseCase {
MockRemoveLabelsFromBookmarkUseCase()
}
func makeGetLabelsUseCase() -> any PGetLabelsUseCase {
MockGetLabelsUseCase()
}
func makeAddTextToSpeechQueueUseCase() -> any PAddTextToSpeechQueueUseCase {
MockAddTextToSpeechQueueUseCase()
}
}
// MARK: Mocked Use Cases
class MockLoginUserCase: PLoginUseCase {
func execute(endpoint: String, username: String, password: String) async throws -> User {
return User(id: "123", token: "abc")
}
}
class MockLogoutUseCase: PLogoutUseCase {
func execute() async throws {}
}
class MockCreateBookmarkUseCase: PCreateBookmarkUseCase {
func execute(createRequest: CreateBookmarkRequest) async throws -> String { "mock-bookmark-id" }
func createFromURL(_ url: String) async throws -> String { "mock-bookmark-id" }
func createFromURLWithTitle(_ url: String, title: String) async throws -> String { "mock-bookmark-id" }
func createFromURLWithLabels(_ url: String, labels: [String]) async throws -> String { "mock-bookmark-id" }
func createFromClipboard() async throws -> String? { "mock-bookmark-id" }
}
class MockGetLabelsUseCase: PGetLabelsUseCase {
func execute() async throws -> [BookmarkLabel] {
[BookmarkLabel(name: "Test", count: 1, href: "mock-href")]
}
}
class MockSearchBookmarksUseCase: PSearchBookmarksUseCase {
func execute(search: String) async throws -> BookmarksPage {
BookmarksPage(bookmarks: [], currentPage: 1, totalCount: 0, totalPages: 1, links: nil)
}
}
class MockReadBookmarkUseCase: PReadBookmarkUseCase {
func execute(bookmarkDetail: BookmarkDetail) {}
}
class MockGetBookmarksUseCase: PGetBookmarksUseCase {
func execute(state: BookmarkState?, limit: Int?, offset: Int?, search: String?, type: [BookmarkType]?, tag: String?) async throws -> BookmarksPage {
BookmarksPage(bookmarks: [], currentPage: 1, totalCount: 0, totalPages: 1, links: nil)
}
}
class MockUpdateBookmarkUseCase: PUpdateBookmarkUseCase {
func execute(bookmarkId: String, updateRequest: BookmarkUpdateRequest) async throws {}
func toggleArchive(bookmarkId: String, isArchived: Bool) async throws {}
func toggleFavorite(bookmarkId: String, isMarked: Bool) async throws {}
func markAsDeleted(bookmarkId: String) async throws {}
func updateReadProgress(bookmarkId: String, progress: Int, anchor: String?) async throws {}
func updateTitle(bookmarkId: String, title: String) async throws {}
func updateLabels(bookmarkId: String, labels: [String]) async throws {}
func addLabels(bookmarkId: String, labels: [String]) async throws {}
func removeLabels(bookmarkId: String, labels: [String]) async throws {}
}
class MockSaveSettingsUseCase: PSaveSettingsUseCase {
func execute(endpoint: String, username: String, password: String) async throws {}
func execute(endpoint: String, username: String, password: String, hasFinishedSetup: Bool) async throws {}
func execute(token: String) async throws {}
func execute(selectedFontFamily: FontFamily, selectedFontSize: FontSize) async throws {}
}
class MockGetBookmarkUseCase: PGetBookmarkUseCase {
func execute(id: String) async throws -> BookmarkDetail {
BookmarkDetail(id: "123", title: "Test", url: "https://www.google.com", description: "Test", siteName: "Test", authors: ["Test"], created: "2021-01-01", updated: "2021-01-01", wordCount: 100, readingTime: 100, hasArticle: true, isMarked: false, isArchived: false, labels: ["Test"], thumbnailUrl: "https://picsum.photos/30/30", imageUrl: "https://picsum.photos/400/400", lang: "en")
}
}
class MockLoadSettingsUseCase: PLoadSettingsUseCase {
func execute() async throws -> Settings? {
Settings(endpoint: "mock-endpoint", username: "mock-user", password: "mock-pw", token: "mock-token", fontFamily: .system, fontSize: .medium, hasFinishedSetup: true)
}
}
class MockDeleteBookmarkUseCase: PDeleteBookmarkUseCase {
func execute(bookmarkId: String) async throws {}
}
class MockGetBookmarkArticleUseCase: PGetBookmarkArticleUseCase {
func execute(id: String) async throws -> String {
let path = Bundle.main.path(forResource: "article", ofType: "html")
return try String(contentsOfFile: path!)
}
}
class MockAddLabelsToBookmarkUseCase: PAddLabelsToBookmarkUseCase {
func execute(bookmarkId: String, labels: [String]) async throws {}
func execute(bookmarkId: String, label: String) async throws {}
}
class MockRemoveLabelsFromBookmarkUseCase: PRemoveLabelsFromBookmarkUseCase {
func execute(bookmarkId: String, labels: [String]) async throws {}
func execute(bookmarkId: String, label: String) async throws {}
}
class MockSaveServerSettingsUseCase: PSaveServerSettingsUseCase {
func execute(endpoint: String, username: String, password: String, token: String) async throws {}
}
class MockAddTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase {
func execute(bookmarkDetail: BookmarkDetail) {}
}

View File

@ -0,0 +1,135 @@
<section>
<h3 id=""><strong> </strong></h3><h2 id="hn.CeDL.why-swiftdata-should-be-isolated">Why SwiftData Should Be Isolated</h2><p>While <strong>SwiftData</strong> provides a smooth developer experience thanks to its macro-based integration and built-in support for <code>@Model</code>, <code>@Query</code>, and <code>@Environment(\.modelContext)</code>, it introduces a major architectural concern:<strong> tight coupling between persistence and the UI layer</strong>.</p><p>When you embed SwiftData directly into your views or view models, you violate clean architecture principles like <strong>separation of concerns</strong> and <strong>dependency inversion</strong>. This makes your code:</p><ul><li><strong>Hard to test:</strong> mocking SwiftData becomes complex or even impossible</li><li><strong>Difficult to swap:</strong> migrating to another persistence mechanism (e.g., in-memory storage for previews or tests) becomes painful</li><li><strong>Less maintainable:</strong> UI logic becomes tightly bound to storage details</li></ul><p>To preserve the <strong>testability</strong>, <strong>flexibility</strong>, and <strong>scalability</strong> of your app, its critical to <strong>isolate SwiftData behind an abstraction</strong>.</p>
<p>
In this tutorial, well focus on how to achieve this isolation by applying SOLID principles, with a special emphasis on the Dependency Inversion Principle. Well show how to decouple SwiftData from the view and the view model, making your app cleaner, safer, and future-proof, ensuring your app&#39;s scalability.
</p>
<blockquote>View the full source code on GitHub: <a href="https://github.com/belkhadir/SwiftDataApp/?ref=swiftorbit.io" rel="nofollow noopener noreferrer">https://github.com/belkhadir/SwiftDataApp/</a></blockquote><h2 id="hn.CeDL.defining-the-boundaries">Defining the Boundaries</h2><p>The example you&#39;ll see shortly is intentionally simple. The goal is clarity, so you can follow along easily and fully grasp the key concepts. But before diving into the code, let&#39;s understand what we mean by <strong>boundaries</strong>, as clearly defined by Uncle Bob (Robert C. Martin):</p>
<blockquote>
<p>“Those boundaries separate software elements from one another, and restrict those on one side from knowing about those on the other.”</p>
</blockquote>
<figure id=""><img src="https://readeck.mnk.any64.de/bm/3Z/3ZPaYQx6tgL2wG8ZMBFzdq/_resources/MVrFafkXKxjHRUR68PpRnp.png" alt="" loading="lazy" id="" width="838" height="658"/><figcaption id=""><span id="">Figure 1: Diagram from </span><i id=""><em id="">Clean Architecture</em></i><span id=""> by Robert C. Martin showing the separation between business rules and database access</span></figcaption></figure><p>In our app, when a user taps the “+” button, we add a new Person. The <strong>UI layer</strong> should neither know nor care about <strong>how or where</strong> the Person is saved. Its sole responsibility is straightforward: <strong>display a list of persons</strong>.</p><p>Using something like @Query directly within our SwiftUI views violates these boundaries. Doing so tightly couples the UI to the persistence mechanism (in our case, SwiftData). This breaks the fundamental principle of <strong>Single Responsibility</strong>, as our views now know too much specific detail about data storage and retrieval.</p><p>In the following sections, well show how to respect these boundaries by carefully isolating the persistence logic from the UI, ensuring each layer remains focused, clean, and maintainable.</p><h2 id="hn.CeDL.abstracting-the-persistence-layer">Abstracting the Persistence Layer</h2><p>First, lets clearly outline our requirements. Our app needs to perform three main actions:</p><ol><li><strong>Add a new person</strong></li><li><strong>Fetch all persons</strong></li><li><strong>Delete a specific person</strong></li></ol><p>To ensure these operations are not directly tied to any specific storage framework (like SwiftData), we encapsulate them inside a <strong>protocol</strong>. Well name this protocol PersonDataStore:</p><pre><code>public protocol PersonDataStore {
func fetchAll() throws -&gt; [Person]
func save(_ person: Person) throws
func delete(_ person: Person) throws
}
</code></pre>
<p>Next, we define our primary <strong>entity</strong>, Person, as a simple struct. Notice it doesnt depend on SwiftData or any other framework:</p><pre><code>public struct Person: Identifiable {
public var id: UUID = UUID()
public let name: String
public init(name: String) {
self.name = name
}
}
</code></pre>
<p>These definitions (PersonDataStore and Person) become the core of our domain, forming a stable abstraction for persistence that other layers can depend upon.</p><h2 id="hn.CeDL.implementing-swiftdata-in-the-infra-layer">Implementing SwiftData in the Infra Layer</h2><p>Now that we have our Domain layer clearly defined, lets implement the persistence logic using <strong>SwiftData</strong>. Well encapsulate the concrete implementation in a dedicated framework called SwiftDataInfra.</p><h3 id="hn.CeDL.defining-the-local-model">Defining the Local Model</h3><p>First, we define a local model called LocalePerson. You might wonder why we create a separate model rather than directly using our domain Person entity. The reason is simple:</p><ul><li>LocalePerson serves as a <strong>SwiftData-specific </strong>model that interacts directly with the SwiftData framework.</li><li>It remains <strong>internal</strong> and <strong>isolated</strong> within the infrastructure layer, never exposed to the outside layers, preserving architectural boundaries.</li></ul><pre><code>import SwiftData
@Model
final class LocalePerson: Identifiable {
@Attribute(.unique) var name: String
init(name: String) {
self.name = name
}
}
</code></pre>
<p>Note that we annotate it with <code>@Model</code> and specify <code>@Attribute(.unique)</code> on the name property, signaling to SwiftData that each persons name must be unique.</p><h3 id="hn.CeDL.implementing-the-persistence-logic">Implementing the Persistence Logic</h3><p>To implement persistence operations (fetch, save, delete), well use SwiftDatas ModelContext. Well inject this context directly into our infrastructure class (SwiftDataPersonDataStore) via constructor injection:</p><pre><code>import Foundation
import SwiftData
import SwiftDataDomain
public final class SwiftDataPersonDataStore {
private let modelContext: ModelContext
public init(modelContext: ModelContext) {
self.modelContext = modelContext
}
}
</code></pre>
<h3 id="hn.CeDL.conforming-to-persondatastore">Conforming to PersonDataStore</h3><p>Our infrastructure class will now conform to our domain protocol <code>PersonDataStore</code>. Heres how each operation is implemented:</p><p><strong>1. Fetching all persons:</strong></p><pre><code>public func fetchAll() throws -&gt; [Person] {
let request = FetchDescriptor&lt;LocalePerson&gt;(sortBy: [SortDescriptor(\.name)])
let results = try modelContext.fetch(request)
return results.map { Person(name: $0.name) }
}
</code></pre>
<ul><li>We use a <code>FetchDescriptor</code> to define our query, sorting persons by their name.</li><li>We map each <code>LocalePerson</code> (infra model) to a plain <code>Person</code> entity (domain model), maintaining isolation from SwiftData specifics.</li></ul><p><strong>2. Saving a person:</strong></p><pre><code>public func save(_ person: Person) throws {
let localPerson = LocalePerson(name: person.name)
modelContext.insert(localPerson)
try modelContext.save()
}
</code></pre>
<ul><li>We create a new <code>LocalePerson</code> instance.</li><li>We insert this instance into SwiftDatas context, then explicitly save the changes.</li></ul><p><strong>3. Deleting a person:</strong></p><pre><code>public func delete(_ person: Person) throws {
let request = FetchDescriptor&lt;LocalePerson&gt;(sortBy: [SortDescriptor(\.name)])
let results = try modelContext.fetch(request)
guard let localPerson = results.first else { return }
modelContext.delete(localPerson)
try modelContext.save()
}
</code></pre>
<ul><li>We fetch the corresponding LocalePerson.</li><li>We delete the fetched object and save the context.</li><li>(Note: For a robust production app, youd typically want to match using unique identifiers rather than just picking the first result.)</li></ul><h2 id="hn.CeDL.viewmodel-that-doesn%E2%80%99t-know-about-swiftdata">ViewModel That Doesnt Know About SwiftData</h2><p>Our ViewModel is placed in a separate framework called <strong>SwiftDataPresentation</strong>, which depends <strong>only</strong> on the Domain layer (SwiftDataDomain). Crucially, this ViewModel knows <strong>nothing</strong> about SwiftData specifics or any persistence details. Its sole responsibility is managing UI state and interactions, displaying persons when the view appears, and handling the addition or deletion of persons through user actions.</p><figure id=""><img src="https://readeck.mnk.any64.de/bm/3Z/3ZPaYQx6tgL2wG8ZMBFzdq/_resources/4vML2Vj2nCR6cC4T8B6nAo.png" alt="" loading="lazy" id="" width="1206" height="2622"/><figcaption id=""><span id="">SwiftUI list view displaying people added using a modular SwiftData architecture, with a clean decoupled ViewModel.</span></figcaption></figure><p>Heres the ViewModel implementation, highlighting dependency injection clearly:</p><pre><code>public final class PersonViewModel {
// Dependency injected through initializer
private let personDataStore: PersonDataStore
// UI state management using ViewState
public private(set) var viewState: ViewState&lt;[Person]&gt; = .idle
public init(personDataStore: PersonDataStore) {
self.personDataStore = personDataStore
}
}
</code></pre>
<h3 id="hn.CeDL.explanation-of-the-injection-and-usage">Explanation of the Injection and Usage</h3><ul><li><strong>Constructor Injection</strong>:<ul><li>The <code>PersonDataStore</code> is injected into the <code>PersonViewModel</code> through its initializer.</li></ul></li><ul><li>By depending only on the <code>PersonDataStore</code> protocol, the ViewModel remains <strong>agnostic</strong> about which persistence implementation its using (SwiftData, Core Data, or even an in-memory store for testing purposes).</li></ul><li><strong>How <code>PersonDataStore</code> is Used</strong>:<ul><li><strong>Loading Data (onAppear)</strong>:</li></ul></li></ul><pre><code>public func onAppear() {
viewState = .loaded(allPersons())
}
</code></pre>
<ul><ul><li><strong>Adding a New Person</strong>:</li></ul></ul><pre><code>public func addPerson(_ person: Person) {
perform { try personDataStore.save(person) }
}
</code></pre>
<p>The ViewModel delegates saving the new person to the injected store, without knowing how or where it happens.</p><ul><ul><li><strong>Deleting a Person</strong>:</li></ul></ul><pre><code>public func deletePerson(at offsets: IndexSet) {
switch viewState {
case .loaded(let people) where !people.isEmpty:
for index in offsets {
let person = people[index]
perform { try personDataStore.delete(person) }
}
default:
break
}
}
</code></pre>
<p>Similarly, deletion is entirely delegated to the injected store, keeping persistence details completely hidden from the ViewModel.</p><h2 id="hn.CeDL.composing-the-app-without-breaking-boundaries">Composing the App Without Breaking Boundaries</h2><p>Now that we&#39;ve built clearly defined layers, Domain, Infrastructure, and Presentation, it&#39;s time to tie everything together into our application. But there&#39;s one important rule: the way we compose our application <strong>shouldn&#39;t compromise our carefully crafted boundaries</strong>.</p><figure id=""><img src="https://readeck.mnk.any64.de/bm/3Z/3ZPaYQx6tgL2wG8ZMBFzdq/_resources/4vBMiXQ2JzCa5Miuce63wa.png" alt="" loading="lazy" id="" width="1280" height="758"/><figcaption id=""><i id=""><em id="">Clean architecture dependency graph for a SwiftUI app using SwiftData, showing separated App, Presentation, Domain, and Infra layers</em></i></figcaption></figure><h3 id="hn.CeDL.application-composition-swiftdataappapp">Application Composition (SwiftDataAppApp)</h3><p>Our application&#39;s entry point, SwiftDataAppApp, acts as the composition root. It has full knowledge of every module, enabling it to wire dependencies together without letting those details leak into the inner layers:</p><pre><code>import SwiftUI
import SwiftData
import SwiftDataInfra
import SwiftDataPresentation
@main
struct SwiftDataAppApp: App {
let container: ModelContainer
init() {
// Creating our SwiftData ModelContainer through a factory method.
do {
container = try SwiftDataInfraContainerFactory.makeContainer()
} catch {
fatalError(&#34;Failed to initialize ModelContainer: \(error)&#34;)
}
}
var body: some Scene {
WindowGroup {
// Constructing the view with dependencies injected.
ListPersonViewContructionView.construct(container: container)
}
}
}
</code></pre>
<h2 id="hn.CeDL.benefits-of-this-isolation">Benefits of This Isolation</h2><p>By encapsulating SwiftData logic within the Infrastructure layer and adhering strictly to the PersonDataStore protocol, weve achieved a powerful separation:</p><ul><li><strong>The Presentation Layer</strong> and <strong>Domain Layer</strong> remain entirely unaware of SwiftData.</li><li>Our code becomes significantly more <strong>testable</strong> and <strong>maintainable</strong>.</li><li>Were free to <strong>change or replace SwiftData</strong> without affecting the rest of the app.</li></ul><h2 id="hn.CeDL.references-and-further-reading"><strong> References and Further Reading</strong></h2><ul><li><strong>Clean Architecture (Robert C. Martin)</strong><br/><a href="https://books.apple.com/us/book/clean-architecture/id1315522850?ref=swiftorbit.io" rel="nofollow noopener noreferrer">https://books.apple.com/us/book/clean-architecture/id1315522850</a></li><li><strong>Essential Developer iOS Development &amp; Architecture Courses</strong><br/><a href="https://www.essentialdeveloper.com/?ref=swiftorbit.io" rel="nofollow noopener noreferrer">https://www.essentialdeveloper.com</a></li><li><strong>Apple Documentation: SwiftData</strong><br/><a href="https://developer.apple.com/documentation/SwiftData?ref=swiftorbit.io" rel="nofollow noopener noreferrer">https://developer.apple.com/documentation/SwiftData</a></li></ul>
</section>

View File

@ -8,7 +8,11 @@
import SwiftUI
struct FontSettingsView: View {
@State private var viewModel = FontSettingsViewModel()
@State private var viewModel: FontSettingsViewModel
init(viewModel: FontSettingsViewModel = FontSettingsViewModel()) {
self.viewModel = viewModel
}
var body: some View {
VStack(alignment: .leading, spacing: 24) {
@ -96,5 +100,7 @@ struct FontSettingsView: View {
}
#Preview {
FontSettingsView()
}
FontSettingsView(viewModel: .init(
factory: MockUseCaseFactory())
)
}

View File

@ -11,8 +11,8 @@ import SwiftUI
@Observable
class FontSettingsViewModel {
private let saveSettingsUseCase: SaveSettingsUseCase
private let loadSettingsUseCase: LoadSettingsUseCase
private let saveSettingsUseCase: PSaveSettingsUseCase
private let loadSettingsUseCase: PLoadSettingsUseCase
// MARK: - Font Settings
var selectedFontFamily: FontFamily = .system
@ -63,8 +63,7 @@ class FontSettingsViewModel {
}
}
init() {
let factory = DefaultUseCaseFactory.shared
init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
self.saveSettingsUseCase = factory.makeSaveSettingsUseCase()
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
}

View File

@ -9,7 +9,11 @@ import SwiftUI
// SectionHeader wird jetzt zentral importiert
struct SettingsGeneralView: View {
@State private var viewModel = SettingsGeneralViewModel()
@State private var viewModel: SettingsGeneralViewModel
init(viewModel: SettingsGeneralViewModel = SettingsGeneralViewModel()) {
self.viewModel = viewModel
}
var body: some View {
VStack(spacing: 20) {
@ -169,5 +173,7 @@ enum Theme: String, CaseIterable {
#Preview {
SettingsGeneralView()
}
SettingsGeneralView(viewModel: .init(
MockUseCaseFactory()
))
}

View File

@ -4,8 +4,8 @@ import SwiftUI
@Observable
class SettingsGeneralViewModel {
private let saveSettingsUseCase: SaveSettingsUseCase
private let loadSettingsUseCase: LoadSettingsUseCase
private let saveSettingsUseCase: PSaveSettingsUseCase
private let loadSettingsUseCase: PLoadSettingsUseCase
// MARK: - UI Settings
var selectedTheme: Theme = .system
@ -26,8 +26,7 @@ class SettingsGeneralViewModel {
// func clearCache() async {}
// func resetSettings() async {}
init() {
let factory = DefaultUseCaseFactory.shared
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
self.saveSettingsUseCase = factory.makeSaveSettingsUseCase()
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
}

View File

@ -11,6 +11,11 @@ struct SettingsServerView: View {
@State private var viewModel = SettingsServerViewModel()
@State private var showingLogoutAlert = false
init(viewModel: SettingsServerViewModel = SettingsServerViewModel(), showingLogoutAlert: Bool = false) {
self.viewModel = viewModel
self.showingLogoutAlert = showingLogoutAlert
}
var body: some View {
VStack(spacing: 20) {
SectionHeader(title: viewModel.isSetupMode ? "Server-Einstellungen" : "Server-Verbindung", icon: "server.rack")
@ -42,9 +47,9 @@ struct SettingsServerView: View {
}
}
VStack(alignment: .leading, spacing: 6) {
Text("Benutzername")
Text("Username")
.font(.headline)
TextField("Ihr Benutzername", text: $viewModel.username)
TextField("Your Username", text: $viewModel.username)
.textFieldStyle(.roundedBorder)
.autocapitalization(.none)
.disableAutocorrection(true)
@ -56,9 +61,9 @@ struct SettingsServerView: View {
}
}
VStack(alignment: .leading, spacing: 6) {
Text("Passwort")
Text("Password")
.font(.headline)
SecureField("Ihr Passwort", text: $viewModel.password)
SecureField("Your Password", text: $viewModel.password)
.textFieldStyle(.roundedBorder)
.disabled(!viewModel.isSetupMode)
.onChange(of: viewModel.password) {
@ -90,6 +95,7 @@ struct SettingsServerView: View {
.font(.caption)
}
}
if let successMessage = viewModel.successMessage {
HStack {
Image(systemName: "checkmark.circle.fill")
@ -165,5 +171,7 @@ struct SettingsServerView: View {
}
#Preview {
SettingsServerView()
SettingsServerView(viewModel: .init(
MockUseCaseFactory()
))
}

View File

@ -7,10 +7,10 @@ class SettingsServerViewModel {
// MARK: - Use Cases
private let loginUseCase: LoginUseCase
private let logoutUseCase: LogoutUseCase
private let saveServerSettingsUseCase: SaveServerSettingsUseCase
private let loadSettingsUseCase: LoadSettingsUseCase
private let loginUseCase: PLoginUseCase
private let logoutUseCase: PLogoutUseCase
private let saveServerSettingsUseCase: PSaveServerSettingsUseCase
private let loadSettingsUseCase: PLoadSettingsUseCase
// MARK: - Server Settings
var endpoint = ""
@ -27,8 +27,7 @@ class SettingsServerViewModel {
SettingsRepository().hasFinishedSetup
}
init() {
let factory = DefaultUseCaseFactory.shared
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) {
self.loginUseCase = factory.makeLoginUseCase()
self.logoutUseCase = factory.makeLogoutUseCase()
self.saveServerSettingsUseCase = factory.makeSaveServerSettingsUseCase()
@ -69,7 +68,6 @@ class SettingsServerViewModel {
successMessage = "Server-Einstellungen gespeichert und erfolgreich angemeldet."
try await SettingsRepository().saveHasFinishedSetup(true)
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
await DefaultUseCaseFactory.shared.refreshConfiguration()
} catch {
errorMessage = "Verbindung oder Anmeldung fehlgeschlagen: \(error.localizedDescription)"
isLoggedIn = false