Refactor authentication, settings, and UI; add search and improve bookmark image handling
- Refactor authentication flow to require endpoint for login and decouple token saving - Add and integrate search functionality for bookmarks - Simplify and improve server settings setup (remove connection test, direct save & login) - Update sidebar/tab navigation to include search and improve structure - Show placeholder image in BookmarkCardView if no image is available, ensuring consistent layout - Improve BookmarkDetailView header and meta info display - Add utility for domain extraction from URLs - General code cleanup and minor UI/UX improvements
This commit is contained in:
parent
ec22c379d1
commit
e88693363b
@ -5,17 +5,17 @@
|
||||
<key>NSExtension</key>
|
||||
<dict>
|
||||
<key>NSExtensionAttributes</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<dict>
|
||||
<key>NSExtensionActivationRule</key>
|
||||
<dict>
|
||||
<key>NSExtensionActivationSupportsWebURLWithMaxCount</key>
|
||||
<integer>1</integer>
|
||||
</dict>
|
||||
</dict>
|
||||
<key>NSExtensionPointIdentifier</key>
|
||||
<string>com.apple.share-services</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
|
||||
<key>NSExtensionPrincipalClass</key>
|
||||
<string>$(PRODUCT_MODULE_NAME).ShareViewController</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@ -415,6 +415,7 @@
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = URLShare/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = URLShare;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@ -423,7 +424,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck.URLShare;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck2.URLShare;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
@ -442,6 +443,7 @@
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = URLShare/Info.plist;
|
||||
INFOPLIST_KEY_CFBundleDisplayName = URLShare;
|
||||
INFOPLIST_KEY_LSApplicationCategoryType = "";
|
||||
INFOPLIST_KEY_NSHumanReadableCopyright = "";
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.1;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
@ -450,7 +452,7 @@
|
||||
"@executable_path/../../Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck.URLShare;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck2.URLShare;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
SKIP_INSTALL = YES;
|
||||
@ -581,6 +583,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||
@ -605,8 +608,9 @@
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.1;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck2;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SDKROOT = auto;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
@ -622,6 +626,7 @@
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
|
||||
@ -646,8 +651,9 @@
|
||||
"LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks";
|
||||
MACOSX_DEPLOYMENT_TARGET = 15.1;
|
||||
MARKETING_VERSION = 1.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = de.ilyashallak.readeck2;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
PROVISIONING_PROFILE_SPECIFIER = "";
|
||||
SDKROOT = auto;
|
||||
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx xros xrsimulator";
|
||||
SWIFT_EMIT_LOC_STRINGS = YES;
|
||||
|
||||
38
readeck/Assets.xcassets/green.colorset/Contents.json
Normal file
38
readeck/Assets.xcassets/green.colorset/Contents.json
Normal file
@ -0,0 +1,38 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x5B",
|
||||
"green" : "0x4D",
|
||||
"red" : "0x00"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
},
|
||||
{
|
||||
"appearances" : [
|
||||
{
|
||||
"appearance" : "luminosity",
|
||||
"value" : "dark"
|
||||
}
|
||||
],
|
||||
"color" : {
|
||||
"color-space" : "srgb",
|
||||
"components" : {
|
||||
"alpha" : "1.000",
|
||||
"blue" : "0x5B",
|
||||
"green" : "0x4D",
|
||||
"red" : "0x00"
|
||||
}
|
||||
},
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
readeck/Assets.xcassets/placeholder.imageset/Bildschirmfoto 2025-07-03 um 15.09.44.png
vendored
Normal file
BIN
readeck/Assets.xcassets/placeholder.imageset/Bildschirmfoto 2025-07-03 um 15.09.44.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
21
readeck/Assets.xcassets/placeholder.imageset/Contents.json
vendored
Normal file
21
readeck/Assets.xcassets/placeholder.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "Bildschirmfoto 2025-07-03 um 15.09.44.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
23
readeck/Assets.xcassets/readeck.imageset/Contents.json
vendored
Normal file
23
readeck/Assets.xcassets/readeck.imageset/Contents.json
vendored
Normal file
@ -0,0 +1,23 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "readeck2@1x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "1x"
|
||||
},
|
||||
{
|
||||
"filename" : "readeck2@2x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "2x"
|
||||
},
|
||||
{
|
||||
"filename" : "readeck2@3x.png",
|
||||
"idiom" : "universal",
|
||||
"scale" : "3x"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
BIN
readeck/Assets.xcassets/readeck.imageset/readeck2@1x.png
vendored
Normal file
BIN
readeck/Assets.xcassets/readeck.imageset/readeck2@1x.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
readeck/Assets.xcassets/readeck.imageset/readeck2@2x.png
vendored
Normal file
BIN
readeck/Assets.xcassets/readeck.imageset/readeck2@2x.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 42 KiB |
BIN
readeck/Assets.xcassets/readeck.imageset/readeck2@3x.png
vendored
Normal file
BIN
readeck/Assets.xcassets/readeck.imageset/readeck2@3x.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 65 KiB |
@ -9,13 +9,14 @@ import Foundation
|
||||
|
||||
protocol PAPI {
|
||||
var tokenProvider: TokenProvider { get }
|
||||
func login(username: String, password: String) async throws -> UserDto
|
||||
func login(endpoint: String, username: String, password: String) async throws -> UserDto
|
||||
func getBookmarks(state: BookmarkState?, limit: Int?, offset: Int?, search: String?, type: [BookmarkType]?) async throws -> BookmarksPageDto
|
||||
func getBookmark(id: String) async throws -> BookmarkDetailDto
|
||||
func getBookmarkArticle(id: String) async throws -> String
|
||||
func createBookmark(createRequest: CreateBookmarkRequestDto) async throws -> CreateBookmarkResponseDto
|
||||
func updateBookmark(id: String, updateRequest: UpdateBookmarkRequestDto) async throws
|
||||
func deleteBookmark(id: String) async throws
|
||||
func searchBookmarks(search: String) async throws -> BookmarksPageDto
|
||||
}
|
||||
|
||||
class API: PAPI {
|
||||
@ -28,10 +29,12 @@ class API: PAPI {
|
||||
|
||||
private var baseURL: String {
|
||||
get async {
|
||||
if let cached = cachedBaseURL {
|
||||
if let cached = cachedBaseURL, cached.isEmpty == false {
|
||||
return cached
|
||||
}
|
||||
let url = await tokenProvider.getEndpoint()
|
||||
guard let url = await tokenProvider.getEndpoint() else {
|
||||
return ""
|
||||
}
|
||||
cachedBaseURL = url
|
||||
return url
|
||||
}
|
||||
@ -154,20 +157,25 @@ class API: PAPI {
|
||||
return string
|
||||
}
|
||||
|
||||
func login(username: String, password: String) async throws -> UserDto {
|
||||
func login(endpoint: String, username: String, password: String) async throws -> UserDto {
|
||||
let loginRequest = LoginRequestDto(application: "api doc", username: username, password: password)
|
||||
let requestData = try JSONEncoder().encode(loginRequest)
|
||||
|
||||
let userDto = try await makeJSONRequest(
|
||||
endpoint: "/api/auth",
|
||||
method: .POST,
|
||||
body: requestData,
|
||||
responseType: UserDto.self
|
||||
)
|
||||
|
||||
// Token automatisch speichern nach erfolgreichem Login
|
||||
await tokenProvider.setToken(userDto.token)
|
||||
|
||||
guard let url = URL(string: endpoint + "/api/auth") else {
|
||||
throw APIError.invalidURL
|
||||
}
|
||||
var request = URLRequest(url: url)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
request.httpBody = requestData
|
||||
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)
|
||||
}
|
||||
let userDto = try JSONDecoder().decode(UserDto.self, from: data)
|
||||
// Token NICHT automatisch speichern, da Settings noch nicht existieren
|
||||
return userDto
|
||||
}
|
||||
|
||||
@ -322,6 +330,26 @@ class API: PAPI {
|
||||
throw APIError.serverError(httpResponse.statusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func searchBookmarks(search: String) async throws -> BookmarksPageDto {
|
||||
let endpoint = "/api/bookmarks?search=\(search.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "")"
|
||||
let (bookmarks, response) = try await makeJSONRequestWithHeaders(
|
||||
endpoint: endpoint,
|
||||
responseType: [BookmarkDto].self
|
||||
)
|
||||
let currentPage = response.value(forHTTPHeaderField: "Current-Page").flatMap { Int($0) }
|
||||
let totalCount = response.value(forHTTPHeaderField: "Total-Count").flatMap { Int($0) }
|
||||
let totalPages = response.value(forHTTPHeaderField: "Total-Pages").flatMap { Int($0) }
|
||||
let linksHeader = response.value(forHTTPHeaderField: "Link")
|
||||
let links = linksHeader?.components(separatedBy: ",")
|
||||
return BookmarksPageDto(
|
||||
bookmarks: bookmarks,
|
||||
currentPage: currentPage,
|
||||
totalCount: totalCount,
|
||||
totalPages: totalPages,
|
||||
links: links
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum HTTPMethod: String {
|
||||
|
||||
@ -9,8 +9,8 @@ class AuthRepository: PAuthRepository {
|
||||
self.settingsRepository = settingsRepository
|
||||
}
|
||||
|
||||
func login(username: String, password: String) async throws -> User {
|
||||
let userDto = try await api.login(username: username, password: password)
|
||||
func login(endpoint: String, username: String, password: String) async throws -> User {
|
||||
let userDto = try await api.login(endpoint: endpoint, username: username, password: password)
|
||||
// Token wird automatisch von der API gespeichert
|
||||
return User(id: userDto.id, token: userDto.token)
|
||||
}
|
||||
|
||||
@ -7,6 +7,7 @@ protocol PBookmarksRepository {
|
||||
func createBookmark(createRequest: CreateBookmarkRequest) async throws -> String
|
||||
func updateBookmark(id: String, updateRequest: BookmarkUpdateRequest) async throws
|
||||
func deleteBookmark(id: String) async throws
|
||||
func searchBookmarks(search: String) async throws -> BookmarksPage
|
||||
}
|
||||
|
||||
class BookmarksRepository: PBookmarksRepository {
|
||||
@ -82,6 +83,11 @@ class BookmarksRepository: PBookmarksRepository {
|
||||
|
||||
try await api.updateBookmark(id: id, updateRequest: dto)
|
||||
}
|
||||
|
||||
func searchBookmarks(search: String) async throws -> BookmarksPage {
|
||||
let bookmarkDtos = try await api.searchBookmarks(search: search)
|
||||
return bookmarkDtos.toDomain()
|
||||
}
|
||||
}
|
||||
|
||||
struct BookmarkDetail {
|
||||
|
||||
@ -28,6 +28,7 @@ protocol PSettingsRepository {
|
||||
func saveUsername(_ username: String) async throws
|
||||
func savePassword(_ password: String) async throws
|
||||
func saveHasFinishedSetup(_ hasFinishedSetup: Bool) async throws
|
||||
func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws
|
||||
var hasFinishedSetup: Bool { get }
|
||||
}
|
||||
|
||||
@ -170,6 +171,40 @@ class SettingsRepository: PSettingsRepository {
|
||||
}
|
||||
}
|
||||
|
||||
func saveServerSettings(endpoint: String, username: String, password: String, 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)
|
||||
let settingEntity: SettingEntity
|
||||
if let existing = settingEntities.first {
|
||||
settingEntity = existing
|
||||
} else {
|
||||
settingEntity = SettingEntity(context: context)
|
||||
}
|
||||
settingEntity.endpoint = endpoint
|
||||
settingEntity.username = username
|
||||
settingEntity.password = password
|
||||
settingEntity.token = token
|
||||
try context.save()
|
||||
// Wenn ein Token gespeichert wird, Setup als abgeschlossen markieren
|
||||
if !token.isEmpty {
|
||||
self.hasFinishedSetup = true
|
||||
DispatchQueue.main.async {
|
||||
NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil)
|
||||
}
|
||||
}
|
||||
continuation.resume()
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func saveUsername(_ username: String) async throws {
|
||||
let context = coreDataManager.context
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@ import Foundation
|
||||
|
||||
protocol TokenProvider {
|
||||
func getToken() async -> String?
|
||||
func getEndpoint() async -> String
|
||||
func getEndpoint() async -> String?
|
||||
func setToken(_ token: String) async
|
||||
func clearToken() async
|
||||
}
|
||||
@ -29,10 +29,10 @@ class CoreDataTokenProvider: TokenProvider {
|
||||
return cachedSettings?.token
|
||||
}
|
||||
|
||||
func getEndpoint() async -> String {
|
||||
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"
|
||||
return cachedSettings?.endpoint
|
||||
}
|
||||
|
||||
func setToken(_ token: String) async {
|
||||
@ -56,4 +56,4 @@ class CoreDataTokenProvider: TokenProvider {
|
||||
print("Failed to clear settings: \(error)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -55,3 +55,12 @@ struct ImageResource {
|
||||
let height: Int
|
||||
let width: Int
|
||||
}
|
||||
|
||||
extension Bookmark: Hashable, Identifiable {
|
||||
func hash(into hasher: inout Hasher) {
|
||||
hasher.combine(id)
|
||||
}
|
||||
static func == (lhs: Bookmark, rhs: Bookmark) -> Bool {
|
||||
lhs.id == rhs.id
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,7 +7,7 @@
|
||||
|
||||
|
||||
protocol PAuthRepository {
|
||||
func login(username: String, password: String) async throws -> User
|
||||
func login(endpoint: String, username: String, password: String) async throws -> User
|
||||
func logout() async throws
|
||||
func getCurrentSettings() async throws -> Settings?
|
||||
}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
|
||||
class LoginUseCase {
|
||||
private let repository: PAuthRepository
|
||||
|
||||
@ -6,7 +5,7 @@ class LoginUseCase {
|
||||
self.repository = repository
|
||||
}
|
||||
|
||||
func execute(username: String, password: String) async throws -> User {
|
||||
return try await repository.login(username: username, password: password)
|
||||
func execute(endpoint: String, username: String, password: String) async throws -> User {
|
||||
return try await repository.login(endpoint: endpoint, username: username, password: password)
|
||||
}
|
||||
}
|
||||
|
||||
13
readeck/Domain/UseCase/SaveServerSettingsUseCase.swift
Normal file
13
readeck/Domain/UseCase/SaveServerSettingsUseCase.swift
Normal file
@ -0,0 +1,13 @@
|
||||
import Foundation
|
||||
|
||||
class SaveServerSettingsUseCase {
|
||||
private let repository: PSettingsRepository
|
||||
|
||||
init(repository: PSettingsRepository) {
|
||||
self.repository = repository
|
||||
}
|
||||
|
||||
func execute(endpoint: String, username: String, password: String, token: String) async throws {
|
||||
try await repository.saveServerSettings(endpoint: endpoint, username: username, password: password, token: token)
|
||||
}
|
||||
}
|
||||
13
readeck/Domain/UseCase/SearchBookmarksUseCase.swift
Normal file
13
readeck/Domain/UseCase/SearchBookmarksUseCase.swift
Normal file
@ -0,0 +1,13 @@
|
||||
import Foundation
|
||||
|
||||
class SearchBookmarksUseCase {
|
||||
private let repository: PBookmarksRepository
|
||||
|
||||
init(repository: PBookmarksRepository) {
|
||||
self.repository = repository
|
||||
}
|
||||
|
||||
func execute(search: String) async throws -> BookmarksPage {
|
||||
return try await repository.searchBookmarks(search: search)
|
||||
}
|
||||
}
|
||||
@ -13,5 +13,12 @@
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
<string>green</string>
|
||||
<key>UIImageName</key>
|
||||
<string>readeck</string>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
|
||||
@ -7,51 +7,25 @@ struct BookmarkDetailView: View {
|
||||
@State private var webViewHeight: CGFloat = 300
|
||||
@State private var showingFontSettings = false
|
||||
|
||||
private let headerHeight: CGFloat = 260
|
||||
|
||||
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 let settings = viewModel.settings, !viewModel.articleContent.isEmpty {
|
||||
WebView(htmlContent: viewModel.articleContent, settings: settings) { height in
|
||||
webViewHeight = height
|
||||
}
|
||||
.frame(height: webViewHeight)
|
||||
} else if viewModel.isLoadingArticle {
|
||||
ProgressView("Lade Artikel...")
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding()
|
||||
GeometryReader { geometry in
|
||||
ScrollView {
|
||||
ZStack(alignment: .top) {
|
||||
headerView(geometry: geometry)
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Color.clear.frame(height: viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight)
|
||||
titleSection
|
||||
Divider().padding(.horizontal)
|
||||
contentSection
|
||||
Spacer(minLength: 40)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
.ignoresSafeArea(edges: .top)
|
||||
}
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: {
|
||||
@ -81,37 +55,95 @@ struct BookmarkDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ViewBuilder
|
||||
|
||||
@ViewBuilder
|
||||
private func headerView(geometry: GeometryProxy) -> some View {
|
||||
if !viewModel.bookmarkDetail.imageUrl.isEmpty {
|
||||
GeometryReader { geo in
|
||||
let offset = geo.frame(in: .global).minY
|
||||
ZStack(alignment: .top) {
|
||||
AsyncImage(url: URL(string: viewModel.bookmarkDetail.imageUrl)) { image in
|
||||
image
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
.frame(width: geometry.size.width, height: headerHeight + (offset > 0 ? offset : 0))
|
||||
.clipped()
|
||||
.offset(y: (offset > 0 ? -offset : 0))
|
||||
} placeholder: {
|
||||
Rectangle()
|
||||
.fill(Color.gray.opacity(0.4))
|
||||
.frame(width: geometry.size.width, height: headerHeight)
|
||||
}
|
||||
LinearGradient(
|
||||
gradient: Gradient(colors: [Color.black.opacity(0.6), Color.clear]),
|
||||
startPoint: .top,
|
||||
endPoint: .bottom
|
||||
)
|
||||
.frame(height: 120)
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
}
|
||||
.frame(height: headerHeight)
|
||||
.ignoresSafeArea(edges: .top)
|
||||
}
|
||||
}
|
||||
|
||||
private var titleSection: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text(viewModel.bookmarkDetail.title)
|
||||
.font(.title2)
|
||||
.fontWeight(.semibold)
|
||||
.foregroundColor(.primary)
|
||||
.padding(.bottom, 2)
|
||||
.shadow(color: Color.black.opacity(0.15), radius: 2, x: 0, y: 1)
|
||||
metaInfoSection
|
||||
}
|
||||
.padding(.horizontal)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var contentSection: some View {
|
||||
if let settings = viewModel.settings, !viewModel.articleContent.isEmpty {
|
||||
WebView(htmlContent: viewModel.articleContent, settings: settings) { height in
|
||||
webViewHeight = height
|
||||
}
|
||||
.frame(height: webViewHeight)
|
||||
.cornerRadius(14)
|
||||
.padding(.horizontal)
|
||||
} else if viewModel.isLoadingArticle {
|
||||
ProgressView("Lade Artikel...")
|
||||
.frame(maxWidth: .infinity, alignment: .center)
|
||||
.padding()
|
||||
} else {
|
||||
Button(action: {
|
||||
SafariUtil.openInSafari(url: viewModel.bookmarkDetail.url)
|
||||
}) {
|
||||
HStack {
|
||||
Image(systemName: "safari")
|
||||
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Original Seite") + " öffnen")
|
||||
}
|
||||
.font(.title3.bold())
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.padding(.horizontal)
|
||||
.padding(.top, 32)
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
metaRow(icon: "person", text: (viewModel.bookmarkDetail.authors.count > 1 ? "Autor:innen: " : "Autor: ") + viewModel.bookmarkDetail.authors.joined(separator: ", "))
|
||||
}
|
||||
|
||||
HStack {
|
||||
Image(systemName: "calendar")
|
||||
Text("Erstellt: \(formatDate(viewModel.bookmarkDetail.created))")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Image(systemName: "textformat")
|
||||
Text("\(viewModel.bookmarkDetail.wordCount ?? 0) Wörter • \(viewModel.bookmarkDetail.readingTime ?? 0) min Lesezeit")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Image(systemName: "safari")
|
||||
metaRow(icon: "calendar", text: formatDate(viewModel.bookmarkDetail.created))
|
||||
metaRow(icon: "textformat", text: "\(viewModel.bookmarkDetail.wordCount ?? 0) Wörter • \(viewModel.bookmarkDetail.readingTime ?? 0) min Lesezeit")
|
||||
metaRow(icon: "safari") {
|
||||
Button(action: {
|
||||
SafariUtil.openInSafari(url: viewModel.bookmarkDetail.url)
|
||||
}) {
|
||||
Text("Original Seite öffnen")
|
||||
Text((URLUtil.extractDomain(from: viewModel.bookmarkDetail.url) ?? "Original Seite") + " öffnen")
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
@ -119,23 +151,36 @@ struct BookmarkDetailView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// ViewBuilder für Meta-Infos
|
||||
@ViewBuilder
|
||||
private func metaRow(icon: String, text: String) -> some View {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
Text(text)
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func metaRow(icon: String, @ViewBuilder content: () -> some View) -> some View {
|
||||
HStack {
|
||||
Image(systemName: icon)
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@ -143,7 +188,6 @@ struct BookmarkDetailView: View {
|
||||
displayFormatter.locale = Locale(identifier: "de_DE")
|
||||
return displayFormatter.string(from: date)
|
||||
}
|
||||
|
||||
return dateString
|
||||
}
|
||||
}
|
||||
|
||||
@ -15,15 +15,13 @@ struct BookmarkCardView: View {
|
||||
image
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(height: 120)
|
||||
} placeholder: {
|
||||
Rectangle()
|
||||
.fill(Color.gray.opacity(0.2))
|
||||
.overlay {
|
||||
Image(systemName: "photo")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
Image("placeholder")
|
||||
.resizable()
|
||||
.aspectRatio(contentMode: .fill)
|
||||
.frame(height: 120)
|
||||
}
|
||||
.frame(height: 120)
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
@ -60,7 +58,7 @@ struct BookmarkCardView: View {
|
||||
}
|
||||
HStack {
|
||||
|
||||
Label("Original Seite öffnen", systemImage: "safari")
|
||||
Label((URLUtil.extractDomain(from: bookmark.url) ?? "Original Seite") + " öffnen", systemImage: "safari")
|
||||
.onTapGesture {
|
||||
SafariUtil.openInSafari(url: bookmark.url)
|
||||
}
|
||||
|
||||
@ -92,8 +92,6 @@ struct BookmarksView: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
.searchable(
|
||||
text: $viewModel.searchQuery, placement: .automatic, prompt: "Search...")
|
||||
}
|
||||
|
||||
// FAB Button - nur bei "Ungelesen" anzeigen
|
||||
|
||||
@ -11,6 +11,8 @@ protocol UseCaseFactory {
|
||||
func makeDeleteBookmarkUseCase() -> DeleteBookmarkUseCase
|
||||
func makeCreateBookmarkUseCase() -> CreateBookmarkUseCase
|
||||
func makeLogoutUseCase() -> LogoutUseCase
|
||||
func makeSearchBookmarksUseCase() -> SearchBookmarksUseCase
|
||||
func makeSaveServerSettingsUseCase() -> SaveServerSettingsUseCase
|
||||
}
|
||||
|
||||
class DefaultUseCaseFactory: UseCaseFactory {
|
||||
@ -68,4 +70,12 @@ class DefaultUseCaseFactory: UseCaseFactory {
|
||||
func makeCreateBookmarkUseCase() -> CreateBookmarkUseCase {
|
||||
return CreateBookmarkUseCase(repository: bookmarksRepository)
|
||||
}
|
||||
|
||||
func makeSearchBookmarksUseCase() -> SearchBookmarksUseCase {
|
||||
return SearchBookmarksUseCase(repository: bookmarksRepository)
|
||||
}
|
||||
|
||||
func makeSaveServerSettingsUseCase() -> SaveServerSettingsUseCase {
|
||||
return SaveServerSettingsUseCase(repository: SettingsRepository())
|
||||
}
|
||||
}
|
||||
|
||||
@ -11,10 +11,12 @@ struct PadSidebarView: View {
|
||||
@State private var selectedTab: SidebarTab = .unread
|
||||
@State private var selectedBookmark: Bookmark?
|
||||
|
||||
private let sidebarTabs: [SidebarTab] = [.search, .all, .unread, .favorite, .archived, .article, .videos, .pictures, .tags]
|
||||
|
||||
var body: some View {
|
||||
NavigationSplitView {
|
||||
List {
|
||||
ForEach(SidebarTab.allCases.filter { $0 != .settings }, id: \.self) { tab in
|
||||
ForEach(sidebarTabs, id: \.self) { tab in
|
||||
Button(action: {
|
||||
selectedTab = tab
|
||||
}) {
|
||||
@ -26,11 +28,11 @@ struct PadSidebarView: View {
|
||||
.listRowBackground(selectedTab == tab ? Color.accentColor.opacity(0.15) : Color.clear)
|
||||
|
||||
if tab == .archived {
|
||||
Spacer(minLength: 20)
|
||||
Spacer()
|
||||
}
|
||||
|
||||
if tab == .pictures {
|
||||
Spacer(minLength: 30)
|
||||
Spacer()
|
||||
Divider()
|
||||
Spacer()
|
||||
}
|
||||
@ -57,6 +59,8 @@ struct PadSidebarView: View {
|
||||
} content: {
|
||||
Group {
|
||||
switch selectedTab {
|
||||
case .search:
|
||||
SearchBookmarksView(selectedBookmark: $selectedBookmark)
|
||||
case .all:
|
||||
BookmarksView(state: .all, type: [.article, .video, .photo], selectedBookmark: $selectedBookmark)
|
||||
case .unread:
|
||||
|
||||
@ -9,7 +9,7 @@ import SwiftUI
|
||||
|
||||
struct PhoneTabView: View {
|
||||
private let mainTabs: [SidebarTab] = [.all, .unread, .favorite, .archived]
|
||||
private let moreTabs: [SidebarTab] = [.article, .videos, .pictures, .tags, .settings]
|
||||
private let moreTabs: [SidebarTab] = [.search, .article, .videos, .pictures, .tags, .settings]
|
||||
|
||||
@State private var selectedMoreTab: SidebarTab? = nil
|
||||
@State private var selectedTabIndex: Int = 0
|
||||
@ -42,7 +42,6 @@ struct PhoneTabView: View {
|
||||
}
|
||||
.tag(mainTabs.count)
|
||||
.onAppear {
|
||||
// Wenn der Mehr-Tab aktiv wird und wir in einer Detailansicht sind, zurücksetzen
|
||||
if selectedTabIndex == mainTabs.count && selectedMoreTab != nil {
|
||||
selectedMoreTab = nil
|
||||
}
|
||||
@ -62,6 +61,8 @@ struct PhoneTabView: View {
|
||||
BookmarksView(state: .favorite, type: [.article], selectedBookmark: .constant(nil))
|
||||
case .archived:
|
||||
BookmarksView(state: .archived, type: [.article], selectedBookmark: .constant(nil))
|
||||
case .search:
|
||||
SearchBookmarksView(selectedBookmark: .constant(nil))
|
||||
case .settings:
|
||||
SettingsView()
|
||||
case .article:
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
//
|
||||
|
||||
enum SidebarTab: Hashable, CaseIterable, Identifiable {
|
||||
case all, unread, favorite, archived, settings, article, videos, pictures, tags
|
||||
case search, all, unread, favorite, archived, article, videos, pictures, tags, settings
|
||||
|
||||
var id: Self { self }
|
||||
|
||||
@ -16,6 +16,7 @@ enum SidebarTab: Hashable, CaseIterable, Identifiable {
|
||||
case .unread: return "Ungelesen"
|
||||
case .favorite: return "Favoriten"
|
||||
case .archived: return "Archiv"
|
||||
case .search: return "Suche"
|
||||
case .settings: return "Einstellungen"
|
||||
case .article: return "Artikel"
|
||||
case .videos: return "Videos"
|
||||
@ -29,6 +30,7 @@ enum SidebarTab: Hashable, CaseIterable, Identifiable {
|
||||
case .unread: return "house"
|
||||
case .favorite: return "heart"
|
||||
case .archived: return "archivebox"
|
||||
case .search: return "magnifyingglass"
|
||||
case .settings: return "gear"
|
||||
case .all: return "list.bullet"
|
||||
case .article: return "doc.plaintext"
|
||||
|
||||
79
readeck/UI/Search/SearchBookmarksView.swift
Normal file
79
readeck/UI/Search/SearchBookmarksView.swift
Normal file
@ -0,0 +1,79 @@
|
||||
import SwiftUI
|
||||
|
||||
struct SearchBookmarksView: View {
|
||||
@State private var viewModel = SearchBookmarksViewModel()
|
||||
@FocusState private var searchFieldIsFocused: Bool
|
||||
@State private var selectedBookmarkId: String?
|
||||
@Binding var selectedBookmark: Bookmark?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundColor(.gray)
|
||||
TextField("Suchbegriff eingeben...", text: $viewModel.searchQuery)
|
||||
.focused($searchFieldIsFocused)
|
||||
.textFieldStyle(PlainTextFieldStyle())
|
||||
.autocapitalization(.none)
|
||||
.disableAutocorrection(true)
|
||||
if !viewModel.searchQuery.isEmpty {
|
||||
Button(action: {
|
||||
viewModel.searchQuery = ""
|
||||
searchFieldIsFocused = true
|
||||
}) {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(.gray)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
}
|
||||
.padding(10)
|
||||
.background(Color(.systemGray6))
|
||||
.cornerRadius(12)
|
||||
.padding([.horizontal, .top])
|
||||
|
||||
if viewModel.isLoading {
|
||||
ProgressView("Suche...")
|
||||
.padding()
|
||||
}
|
||||
|
||||
if let error = viewModel.errorMessage {
|
||||
Text(error)
|
||||
.foregroundColor(.red)
|
||||
.padding()
|
||||
}
|
||||
|
||||
if let bookmarks = viewModel.bookmarks?.bookmarks, !bookmarks.isEmpty {
|
||||
List(bookmarks) { bookmark in
|
||||
Button(action: {
|
||||
|
||||
if UIDevice.isPhone {
|
||||
selectedBookmarkId = bookmark.id
|
||||
} else {
|
||||
if selectedBookmark?.id == bookmark.id {
|
||||
selectedBookmark = nil
|
||||
DispatchQueue.main.async {
|
||||
selectedBookmark = bookmark
|
||||
}
|
||||
} else {
|
||||
selectedBookmark = bookmark
|
||||
}
|
||||
}
|
||||
}) {
|
||||
BookmarkCardView(bookmark: bookmark, currentState: .all, onArchive: {_ in }, onDelete: {_ in }, onToggleFavorite: {_ in })
|
||||
.listRowBackground(Color(.systemBackground))
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.listRowSeparator(.hidden)
|
||||
}
|
||||
.listStyle(.plain)
|
||||
} else if !viewModel.isLoading && viewModel.bookmarks != nil {
|
||||
ContentUnavailableView("Keine Ergebnisse", systemImage: "magnifyingglass", description: Text("Keine Bookmarks gefunden."))
|
||||
.padding()
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
.navigationTitle("Suche")
|
||||
}
|
||||
}
|
||||
49
readeck/UI/Search/SearchBookmarksViewModel.swift
Normal file
49
readeck/UI/Search/SearchBookmarksViewModel.swift
Normal file
@ -0,0 +1,49 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
|
||||
@Observable
|
||||
class SearchBookmarksViewModel {
|
||||
private let searchBookmarksUseCase = DefaultUseCaseFactory.shared.makeSearchBookmarksUseCase()
|
||||
|
||||
var searchQuery: String = "" {
|
||||
didSet {
|
||||
throttleSearch()
|
||||
}
|
||||
}
|
||||
var bookmarks: BookmarksPage? = nil
|
||||
var isLoading = false
|
||||
var errorMessage: String? = nil
|
||||
|
||||
private var searchWorkItem: DispatchWorkItem?
|
||||
|
||||
private func throttleSearch() {
|
||||
searchWorkItem?.cancel()
|
||||
let workItem = DispatchWorkItem { [weak self] in
|
||||
guard let self = self else { return }
|
||||
Task {
|
||||
await self.search()
|
||||
}
|
||||
}
|
||||
searchWorkItem = workItem
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3, execute: workItem)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func search() async {
|
||||
guard !searchQuery.isEmpty else {
|
||||
bookmarks = nil
|
||||
return
|
||||
}
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
do {
|
||||
let result = try await searchBookmarksUseCase.execute(search: searchQuery)
|
||||
bookmarks = result
|
||||
} catch {
|
||||
errorMessage = "Fehler bei der Suche"
|
||||
bookmarks = nil
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
}
|
||||
@ -6,12 +6,9 @@
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
// SectionHeader wird jetzt zentral importiert
|
||||
|
||||
struct SettingsServerView: View {
|
||||
@State private var viewModel = SettingsServerViewModel()
|
||||
@State private var isTesting: Bool = false
|
||||
@State private var connectionTestSuccess: Bool = false
|
||||
@State private var showingLogoutAlert = false
|
||||
|
||||
var body: some View {
|
||||
@ -41,7 +38,6 @@ struct SettingsServerView: View {
|
||||
.onChange(of: viewModel.endpoint) {
|
||||
if viewModel.isSetupMode {
|
||||
viewModel.clearMessages()
|
||||
connectionTestSuccess = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -56,7 +52,6 @@ struct SettingsServerView: View {
|
||||
.onChange(of: viewModel.username) {
|
||||
if viewModel.isSetupMode {
|
||||
viewModel.clearMessages()
|
||||
connectionTestSuccess = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -69,7 +64,6 @@ struct SettingsServerView: View {
|
||||
.onChange(of: viewModel.password) {
|
||||
if viewModel.isSetupMode {
|
||||
viewModel.clearMessages()
|
||||
connectionTestSuccess = false
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -106,34 +100,11 @@ struct SettingsServerView: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Action Buttons
|
||||
if viewModel.isSetupMode {
|
||||
VStack(spacing: 10) {
|
||||
Button(action: {
|
||||
Task {
|
||||
await testConnection()
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
if isTesting {
|
||||
ProgressView()
|
||||
.scaleEffect(0.8)
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
}
|
||||
Text(isTesting ? "Teste Verbindung..." : "Verbindung testen")
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background(viewModel.canLogin ? Color.accentColor : Color.gray)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
.disabled(!viewModel.canLogin || isTesting || viewModel.isLoading)
|
||||
|
||||
Button(action: {
|
||||
Task {
|
||||
await viewModel.login()
|
||||
await viewModel.saveServerSettings()
|
||||
}
|
||||
}) {
|
||||
HStack {
|
||||
@ -142,17 +113,16 @@ struct SettingsServerView: View {
|
||||
.scaleEffect(0.8)
|
||||
.progressViewStyle(CircularProgressViewStyle(tint: .white))
|
||||
}
|
||||
Text(viewModel.isLoading ? "Anmelde..." : (viewModel.isLoggedIn ? "Erneut anmelden" : "Anmelden"))
|
||||
Text(viewModel.isLoading ? "Speichern..." : (viewModel.isLoggedIn ? "Erneut anmelden & speichern" : "Anmelden & speichern"))
|
||||
.fontWeight(.semibold)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding()
|
||||
.background((viewModel.canLogin && connectionTestSuccess) ? Color.blue : Color.gray)
|
||||
.background(viewModel.canLogin ? Color.accentColor : Color.gray)
|
||||
.foregroundColor(.white)
|
||||
.cornerRadius(10)
|
||||
}
|
||||
.disabled(!viewModel.canLogin || !connectionTestSuccess || viewModel.isLoading || isTesting)
|
||||
|
||||
.disabled(!viewModel.canLogin || viewModel.isLoading)
|
||||
Button("Debug-Anmeldung") {
|
||||
viewModel.username = "admin"
|
||||
viewModel.password = "Diggah123"
|
||||
@ -192,12 +162,6 @@ struct SettingsServerView: View {
|
||||
await viewModel.loadServerSettings()
|
||||
}
|
||||
}
|
||||
|
||||
private func testConnection() async {
|
||||
isTesting = true
|
||||
connectionTestSuccess = await viewModel.testConnection()
|
||||
isTesting = false
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
|
||||
@ -4,11 +4,13 @@ import SwiftUI
|
||||
|
||||
@Observable
|
||||
class SettingsServerViewModel {
|
||||
|
||||
// MARK: - Use Cases
|
||||
|
||||
private let loginUseCase: LoginUseCase
|
||||
private let logoutUseCase: LogoutUseCase
|
||||
private let saveSettingsUseCase: SaveSettingsUseCase
|
||||
private let saveServerSettingsUseCase: SaveServerSettingsUseCase
|
||||
private let loadSettingsUseCase: LoadSettingsUseCase
|
||||
private let settingsRepository: SettingsRepository
|
||||
|
||||
// MARK: - Server Settings
|
||||
var endpoint = ""
|
||||
@ -16,21 +18,25 @@ class SettingsServerViewModel {
|
||||
var password = ""
|
||||
var isLoading = false
|
||||
var isLoggedIn = false
|
||||
|
||||
// MARK: - Messages
|
||||
var errorMessage: String?
|
||||
var successMessage: String?
|
||||
|
||||
private var hasFinishedSetup: Bool {
|
||||
SettingsRepository().hasFinishedSetup
|
||||
}
|
||||
|
||||
init() {
|
||||
let factory = DefaultUseCaseFactory.shared
|
||||
self.loginUseCase = factory.makeLoginUseCase()
|
||||
self.logoutUseCase = factory.makeLogoutUseCase()
|
||||
self.saveSettingsUseCase = factory.makeSaveSettingsUseCase()
|
||||
self.saveServerSettingsUseCase = factory.makeSaveServerSettingsUseCase()
|
||||
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
|
||||
self.settingsRepository = SettingsRepository()
|
||||
}
|
||||
|
||||
var isSetupMode: Bool {
|
||||
!settingsRepository.hasFinishedSetup
|
||||
!hasFinishedSetup
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@ -49,63 +55,25 @@ class SettingsServerViewModel {
|
||||
|
||||
@MainActor
|
||||
func saveServerSettings() async {
|
||||
do {
|
||||
try await saveSettingsUseCase.execute(
|
||||
endpoint: endpoint,
|
||||
username: username,
|
||||
password: password
|
||||
)
|
||||
successMessage = "Server-Einstellungen gespeichert"
|
||||
} catch {
|
||||
errorMessage = "Fehler beim Speichern der Server-Einstellungen"
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func testConnection() async -> Bool {
|
||||
guard canLogin else {
|
||||
errorMessage = "Bitte füllen Sie alle Felder aus."
|
||||
return false
|
||||
return
|
||||
}
|
||||
|
||||
clearMessages()
|
||||
|
||||
do {
|
||||
// Test login without saving settings
|
||||
let _ = try await loginUseCase.execute(
|
||||
username: username.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
password: password
|
||||
)
|
||||
|
||||
|
||||
successMessage = "Verbindung erfolgreich getestet! ✓"
|
||||
|
||||
return true
|
||||
|
||||
} catch {
|
||||
errorMessage = "Verbindungstest fehlgeschlagen: \(error.localizedDescription)"
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func login() async {
|
||||
isLoading = true
|
||||
errorMessage = nil
|
||||
successMessage = nil
|
||||
defer { isLoading = false }
|
||||
do {
|
||||
let _ = try await loginUseCase.execute(username: username, password: password)
|
||||
let user = try await loginUseCase.execute(endpoint: endpoint, username: username.trimmingCharacters(in: .whitespacesAndNewlines), password: password)
|
||||
try await saveServerSettingsUseCase.execute(endpoint: endpoint, username: username, password: password, token: user.token)
|
||||
isLoggedIn = true
|
||||
successMessage = "Erfolgreich angemeldet"
|
||||
try await settingsRepository.saveHasFinishedSetup(true)
|
||||
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 = "Anmeldung fehlgeschlagen"
|
||||
errorMessage = "Verbindung oder Anmeldung fehlgeschlagen: \(error.localizedDescription)"
|
||||
isLoggedIn = false
|
||||
}
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@ -125,9 +93,6 @@ class SettingsServerViewModel {
|
||||
successMessage = nil
|
||||
}
|
||||
|
||||
var canSave: Bool {
|
||||
!endpoint.isEmpty && !username.isEmpty && !password.isEmpty
|
||||
}
|
||||
var canLogin: Bool {
|
||||
!username.isEmpty && !password.isEmpty
|
||||
}
|
||||
|
||||
@ -23,3 +23,10 @@ class SafariUtil {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct URLUtil {
|
||||
static func extractDomain(from urlString: String) -> String? {
|
||||
guard let url = URL(string: urlString), let host = url.host else { return nil }
|
||||
return host.replacingOccurrences(of: "www.", with: "")
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user