diff --git a/URLShare/ShareBookmarkView.swift b/URLShare/ShareBookmarkView.swift index 8a712cc..55ab588 100644 --- a/URLShare/ShareBookmarkView.swift +++ b/URLShare/ShareBookmarkView.swift @@ -6,7 +6,7 @@ struct ShareBookmarkView: View { @FocusState private var focusedField: AddBookmarkFieldFocus? private func dismissKeyboard() { - NotificationCenter.default.post(name: NSNotification.Name("DismissKeyboard"), object: nil) + NotificationCenter.default.post(name: .dismissKeyboard, object: nil) } var body: some View { @@ -140,7 +140,6 @@ struct ShareBookmarkView: View { selectedLabels: viewModel.selectedLabels, searchText: $viewModel.searchText, isLabelsLoading: false, - availableLabelPages: convertToBookmarkLabelPages(viewModel.availableLabelPages), filteredLabels: convertToBookmarkLabels(viewModel.filteredLabels), searchFieldFocus: $focusedField, onAddCustomTag: { diff --git a/URLShare/ShareBookmarkViewModel.swift b/URLShare/ShareBookmarkViewModel.swift index 5027275..97c4dac 100644 --- a/URLShare/ShareBookmarkViewModel.swift +++ b/URLShare/ShareBookmarkViewModel.swift @@ -15,13 +15,12 @@ class ShareBookmarkViewModel: ObservableObject { let extensionContext: NSExtensionContext? private let logger = Logger.viewModel - - // Computed properties for pagination + var availableLabels: [BookmarkLabelDto] { return labels.filter { !selectedLabels.contains($0.name) } } - // Computed property for filtered labels based on search text + // filtered labels based on search text var filteredLabels: [BookmarkLabelDto] { if searchText.isEmpty { return availableLabels diff --git a/URLShare/ShareViewController.swift b/URLShare/ShareViewController.swift index 65b3d92..64a511d 100644 --- a/URLShare/ShareViewController.swift +++ b/URLShare/ShareViewController.swift @@ -34,7 +34,7 @@ class ShareViewController: UIViewController { NotificationCenter.default.addObserver( self, selector: #selector(dismissKeyboard), - name: NSNotification.Name("DismissKeyboard"), + name: .dismissKeyboard, object: nil ) } diff --git a/URLShare/SimpleAPI.swift b/URLShare/SimpleAPI.swift index 38b658b..5e4564e 100644 --- a/URLShare/SimpleAPI.swift +++ b/URLShare/SimpleAPI.swift @@ -41,7 +41,7 @@ class SimpleAPI { guard 200...299 ~= httpResponse.statusCode else { if httpResponse.statusCode == 401 { DispatchQueue.main.async { - NotificationCenter.default.post(name: NSNotification.Name("UnauthorizedAPIResponse"), object: nil) + NotificationCenter.default.post(name: .unauthorizedAPIResponse, object: nil) } } let msg = String(data: data, encoding: .utf8) ?? "Unknown error" @@ -94,7 +94,7 @@ class SimpleAPI { guard 200...299 ~= httpResponse.statusCode else { if httpResponse.statusCode == 401 { DispatchQueue.main.async { - NotificationCenter.default.post(name: NSNotification.Name("UnauthorizedAPIResponse"), object: nil) + NotificationCenter.default.post(name: .unauthorizedAPIResponse, object: nil) } } let msg = String(data: data, encoding: .utf8) ?? "Unknown error" diff --git a/readeck.xcodeproj/project.pbxproj b/readeck.xcodeproj/project.pbxproj index 0922cfd..04bb1dd 100644 --- a/readeck.xcodeproj/project.pbxproj +++ b/readeck.xcodeproj/project.pbxproj @@ -9,6 +9,7 @@ /* Begin PBXBuildFile section */ 5D2B7FB92DFA27A400EBDB2B /* URLShare.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 5D2B7FAF2DFA27A400EBDB2B /* URLShare.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */ = {isa = PBXBuildFile; productRef = 5D348CC22E0C9F4F00D0AF21 /* netfox */; }; + 5D9D95492E623668009AF769 /* Kingfisher in Frameworks */ = {isa = PBXBuildFile; productRef = 5D9D95482E623668009AF769 /* Kingfisher */; }; 5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 5DA241FA2E17C3B3007531C3 /* RswiftLibrary */; }; /* End PBXBuildFile section */ @@ -90,6 +91,7 @@ UI/Components/CustomTextFieldStyle.swift, UI/Components/TagManagementView.swift, UI/Components/UnifiedLabelChip.swift, + UI/Utils/NotificationNames.swift, ); target = 5D2B7FAE2DFA27A400EBDB2B /* URLShare */; }; @@ -144,6 +146,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 5D9D95492E623668009AF769 /* Kingfisher in Frameworks */, 5D348CC32E0C9F4F00D0AF21 /* netfox in Frameworks */, 5DA241FB2E17C3B3007531C3 /* RswiftLibrary in Frameworks */, ); @@ -236,6 +239,7 @@ packageProductDependencies = ( 5D348CC22E0C9F4F00D0AF21 /* netfox */, 5DA241FA2E17C3B3007531C3 /* RswiftLibrary */, + 5D9D95482E623668009AF769 /* Kingfisher */, ); productName = readeck; productReference = 5D45F9C82DF858680048D5B8 /* readeck.app */; @@ -326,6 +330,7 @@ packageReferences = ( 5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */, 5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */, + 5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */, ); preferredProjectObjectVersion = 77; productRefGroup = 5D45F9C92DF858680048D5B8 /* Products */; @@ -847,6 +852,14 @@ minimumVersion = 1.21.0; }; }; + 5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/onevcat/Kingfisher.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 8.5.0; + }; + }; 5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/mac-cain13/R.swift.git"; @@ -863,6 +876,11 @@ package = 5D348CC12E0C9F4F00D0AF21 /* XCRemoteSwiftPackageReference "netfox" */; productName = netfox; }; + 5D9D95482E623668009AF769 /* Kingfisher */ = { + isa = XCSwiftPackageProductDependency; + package = 5D9D95472E623668009AF769 /* XCRemoteSwiftPackageReference "Kingfisher" */; + productName = Kingfisher; + }; 5DA241FA2E17C3B3007531C3 /* RswiftLibrary */ = { isa = XCSwiftPackageProductDependency; package = 5DA241F92E17C3B3007531C3 /* XCRemoteSwiftPackageReference "R.swift" */; diff --git a/readeck.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/readeck.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 1623357..9506a6d 100644 --- a/readeck.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/readeck.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "ad0d6bd7e4d278f825d201974f944ef5a8c72a7d757d551070c5da6f64b45150", + "originHash" : "23641a762ee1f352c85f7c3a1e980d54670907541f34888222e901374fcaa088", "pins" : [ + { + "identity" : "kingfisher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/onevcat/Kingfisher.git", + "state" : { + "revision" : "2015fda791daa72c8058619545a593bf8c1dd59f", + "version" : "8.5.0" + } + }, { "identity" : "netfox", "kind" : "remoteSourceControl", diff --git a/readeck/Assets.xcassets/placeholder.imageset/Contents.json b/readeck/Assets.xcassets/placeholder.imageset/Contents.json index ed3dfcc..a7569b9 100644 --- a/readeck/Assets.xcassets/placeholder.imageset/Contents.json +++ b/readeck/Assets.xcassets/placeholder.imageset/Contents.json @@ -2,16 +2,7 @@ "images" : [ { "filename" : "Bildschirmfoto 2025-07-03 um 15.09.44.png", - "idiom" : "universal", - "scale" : "1x" - }, - { - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" + "idiom" : "universal" } ], "info" : { diff --git a/readeck/Data/API/API.swift b/readeck/Data/API/API.swift index 36856fb..455b6eb 100644 --- a/readeck/Data/API/API.swift +++ b/readeck/Data/API/API.swift @@ -45,7 +45,7 @@ class API: PAPI { private func handleUnauthorizedResponse(_ statusCode: Int) { if statusCode == 401 { DispatchQueue.main.async { - NotificationCenter.default.post(name: NSNotification.Name("UnauthorizedAPIResponse"), object: nil) + NotificationCenter.default.post(name: .unauthorizedAPIResponse, object: nil) } } } diff --git a/readeck/Data/Repository/OfflineSyncManager.swift b/readeck/Data/Repository/OfflineSyncManager.swift index 0ca8d93..b9335fa 100644 --- a/readeck/Data/Repository/OfflineSyncManager.swift +++ b/readeck/Data/Repository/OfflineSyncManager.swift @@ -119,7 +119,7 @@ class OfflineSyncManager: ObservableObject { func startAutoSync() { // Monitor server connectivity and auto-sync when server becomes reachable NotificationCenter.default.addObserver( - forName: NSNotification.Name("ServerDidBecomeAvailable"), + forName: .serverDidBecomeAvailable, object: nil, queue: .main ) { [weak self] _ in diff --git a/readeck/Data/Repository/SettingsRepository.swift b/readeck/Data/Repository/SettingsRepository.swift index a865d47..ada23fb 100644 --- a/readeck/Data/Repository/SettingsRepository.swift +++ b/readeck/Data/Repository/SettingsRepository.swift @@ -12,6 +12,7 @@ struct Settings { var hasFinishedSetup: Bool = false var enableTTS: Bool? = nil var theme: Theme? = nil + var cardLayoutStyle: CardLayoutStyle? = nil var isLoggedIn: Bool { token != nil && !token!.isEmpty @@ -31,6 +32,8 @@ protocol PSettingsRepository { func savePassword(_ password: String) async throws func saveHasFinishedSetup(_ hasFinishedSetup: Bool) async throws func saveServerSettings(endpoint: String, username: String, password: String, token: String) async throws + func saveCardLayoutStyle(_ cardLayoutStyle: CardLayoutStyle) async throws + func loadCardLayoutStyle() async throws -> CardLayoutStyle var hasFinishedSetup: Bool { get } } @@ -79,6 +82,10 @@ class SettingsRepository: PSettingsRepository { existingSettings.theme = theme.rawValue } + if let cardLayoutStyle = settings.cardLayoutStyle { + existingSettings.cardLayoutStyle = cardLayoutStyle.rawValue + } + try context.save() continuation.resume() } catch { @@ -115,7 +122,8 @@ class SettingsRepository: PSettingsRepository { fontFamily: FontFamily(rawValue: settingEntity?.fontFamily ?? FontFamily.system.rawValue), fontSize: FontSize(rawValue: settingEntity?.fontSize ?? FontSize.medium.rawValue), enableTTS: settingEntity?.enableTTS, - theme: Theme(rawValue: settingEntity?.theme ?? Theme.system.rawValue) + theme: Theme(rawValue: settingEntity?.theme ?? Theme.system.rawValue), + cardLayoutStyle: CardLayoutStyle(rawValue: settingEntity?.cardLayoutStyle ?? CardLayoutStyle.magazine.rawValue) ) continuation.resume(returning: settings) } catch { @@ -160,7 +168,7 @@ class SettingsRepository: PSettingsRepository { self.hasFinishedSetup = true // Notification senden, dass sich der Setup-Status geändert hat DispatchQueue.main.async { - NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil) + NotificationCenter.default.post(name: .setupStatusChanged, object: nil) } } } @@ -174,7 +182,7 @@ class SettingsRepository: PSettingsRepository { if !token.isEmpty { self.hasFinishedSetup = true DispatchQueue.main.async { - NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil) + NotificationCenter.default.post(name: .setupStatusChanged, object: nil) } } } @@ -192,7 +200,7 @@ class SettingsRepository: PSettingsRepository { self.hasFinishedSetup = hasFinishedSetup // Notification senden, dass sich der Setup-Status geändert hat DispatchQueue.main.async { - NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil) + NotificationCenter.default.post(name: .setupStatusChanged, object: nil) } continuation.resume() } @@ -206,4 +214,45 @@ class SettingsRepository: PSettingsRepository { userDefault.set(newValue, forKey: "hasFinishedSetup") } } + + func saveCardLayoutStyle(_ cardLayoutStyle: CardLayoutStyle) async throws { + let context = coreDataManager.context + + return try await withCheckedThrowingContinuation { continuation in + context.perform { + do { + let fetchRequest: NSFetchRequest = SettingEntity.fetchRequest() + let existingSettings = try context.fetch(fetchRequest).first ?? SettingEntity(context: context) + + existingSettings.cardLayoutStyle = cardLayoutStyle.rawValue + + try context.save() + continuation.resume() + } catch { + continuation.resume(throwing: error) + } + } + } + } + + func loadCardLayoutStyle() async throws -> CardLayoutStyle { + let context = coreDataManager.context + + return try await withCheckedThrowingContinuation { continuation in + context.perform { + do { + let fetchRequest: NSFetchRequest = SettingEntity.fetchRequest() + fetchRequest.fetchLimit = 1 + + let settingEntities = try context.fetch(fetchRequest) + let settingEntity = settingEntities.first + + let cardLayoutStyle = CardLayoutStyle(rawValue: settingEntity?.cardLayoutStyle ?? CardLayoutStyle.magazine.rawValue) ?? .magazine + continuation.resume(returning: cardLayoutStyle) + } catch { + continuation.resume(throwing: error) + } + } + } + } } diff --git a/readeck/Data/Utils/NetworkConnectivity.swift b/readeck/Data/Utils/NetworkConnectivity.swift index b330cb7..19e8aed 100644 --- a/readeck/Data/Utils/NetworkConnectivity.swift +++ b/readeck/Data/Utils/NetworkConnectivity.swift @@ -25,7 +25,7 @@ class ServerConnectivity: ObservableObject { // Notify when server becomes available if !wasReachable && serverReachable { - NotificationCenter.default.post(name: NSNotification.Name("ServerDidBecomeAvailable"), object: nil) + NotificationCenter.default.post(name: .serverDidBecomeAvailable, object: nil) } } } diff --git a/readeck/Domain/Model/CardLayoutStyle.swift b/readeck/Domain/Model/CardLayoutStyle.swift new file mode 100644 index 0000000..45fe184 --- /dev/null +++ b/readeck/Domain/Model/CardLayoutStyle.swift @@ -0,0 +1,29 @@ +import Foundation + +enum CardLayoutStyle: String, CaseIterable, Codable { + case compact = "compact" + case magazine = "magazine" + case natural = "natural" + + var displayName: String { + switch self { + case .compact: + return "Compact" + case .magazine: + return "Magazine" + case .natural: + return "Natural" + } + } + + var description: String { + switch self { + case .compact: + return "Small thumbnails with content focus" + case .magazine: + return "Fixed height headers for consistent layout" + case .natural: + return "Images in original aspect ratio" + } + } +} \ No newline at end of file diff --git a/readeck/Domain/UseCase/LoadCardLayoutUseCase.swift b/readeck/Domain/UseCase/LoadCardLayoutUseCase.swift new file mode 100644 index 0000000..3c93cd8 --- /dev/null +++ b/readeck/Domain/UseCase/LoadCardLayoutUseCase.swift @@ -0,0 +1,21 @@ +import Foundation + +protocol PLoadCardLayoutUseCase { + func execute() async -> CardLayoutStyle +} + +class LoadCardLayoutUseCase: PLoadCardLayoutUseCase { + private let settingsRepository: PSettingsRepository + + init(settingsRepository: PSettingsRepository) { + self.settingsRepository = settingsRepository + } + + func execute() async -> CardLayoutStyle { + do { + return try await settingsRepository.loadCardLayoutStyle() + } catch { + return .magazine + } + } +} \ No newline at end of file diff --git a/readeck/Domain/UseCase/SaveCardLayoutUseCase.swift b/readeck/Domain/UseCase/SaveCardLayoutUseCase.swift new file mode 100644 index 0000000..8329b66 --- /dev/null +++ b/readeck/Domain/UseCase/SaveCardLayoutUseCase.swift @@ -0,0 +1,22 @@ +import Foundation + +protocol PSaveCardLayoutUseCase { + func execute(layout: CardLayoutStyle) async +} + +class SaveCardLayoutUseCase: PSaveCardLayoutUseCase { + private let settingsRepository: PSettingsRepository + private let logger = Logger.data + + init(settingsRepository: PSettingsRepository) { + self.settingsRepository = settingsRepository + } + + func execute(layout: CardLayoutStyle) async { + do { + try await settingsRepository.saveCardLayoutStyle(layout) + } catch { + logger.error("Failed to save card layout style: \(error)") + } + } +} \ No newline at end of file diff --git a/readeck/UI/AddBookmark/AddBookmarkView.swift b/readeck/UI/AddBookmark/AddBookmarkView.swift index 2385f8d..223243a 100644 --- a/readeck/UI/AddBookmark/AddBookmarkView.swift +++ b/readeck/UI/AddBookmark/AddBookmarkView.swift @@ -74,11 +74,9 @@ struct AddBookmarkView: View { ScrollViewReader { proxy in ScrollView { VStack(spacing: 20) { - VStack(spacing: 20) { + VStack(spacing: 16) { urlField .id("urlField") - Spacer() - .frame(height: 40) .id("labelsOffset") labelsField .id("labelsField") @@ -160,10 +158,11 @@ struct AddBookmarkView: View { } } } - .padding() + .padding(12) .background(Color(.systemGray6)) - .clipShape(RoundedRectangle(cornerRadius: 12)) + .clipShape(RoundedRectangle(cornerRadius: 8)) .transition(.opacity.combined(with: .move(edge: .top))) + .padding(.top, 4) } } @@ -183,7 +182,6 @@ struct AddBookmarkView: View { selectedLabels: viewModel.selectedLabels, searchText: $viewModel.searchText, isLabelsLoading: viewModel.isLabelsLoading, - availableLabelPages: viewModel.availableLabelPages, filteredLabels: viewModel.filteredLabels, searchFieldFocus: $focusedField, onAddCustomTag: { diff --git a/readeck/UI/AddBookmark/AddBookmarkViewModel.swift b/readeck/UI/AddBookmark/AddBookmarkViewModel.swift index fabe3ee..6ad8135 100644 --- a/readeck/UI/AddBookmark/AddBookmarkViewModel.swift +++ b/readeck/UI/AddBookmark/AddBookmarkViewModel.swift @@ -59,19 +59,6 @@ class AddBookmarkViewModel { } } - var availableLabelPages: [[BookmarkLabel]] { - let pageSize = Constants.Labels.pageSize - let labelsToShow = searchText.isEmpty ? availableLabels : filteredLabels - - if labelsToShow.count <= pageSize { - return [labelsToShow] - } else { - return stride(from: 0, to: labelsToShow.count, by: pageSize).map { - Array(labelsToShow[$0.. 0 ? offset : 0)) .clipped() - .offset(y: (offset > 0 ? -offset : 0)) - .if(namespace != nil) { view in - view.matchedGeometryEffect(id: "image-\(viewModel.bookmarkDetail.id)", in: namespace!) - } - } placeholder: { - Rectangle() - .fill(Color.gray.opacity(0.4)) - .frame(width: geometry.size.width, height: headerHeight) - .if(namespace != nil) { view in - view.matchedGeometryEffect(id: "image-\(viewModel.bookmarkDetail.id)", in: namespace!) - } - } - // Gradient overlay für bessere Button-Sichtbarkeit - LinearGradient( - gradient: Gradient(colors: [ - Color.black.opacity(1.0), - Color.black.opacity(0.9), - Color.black.opacity(0.7), - Color.black.opacity(0.4), - Color.black.opacity(0.2), - Color.clear - ]), - startPoint: .top, - endPoint: .bottom - ) - .frame(height: 240) - .frame(maxWidth: .infinity) - .offset(y: (offset > 0 ? -offset : 0)) + .offset(y: (offset > 0 ? -offset : 0)) // Tap area and zoom icon VStack { diff --git a/readeck/UI/BookmarkDetail/BookmarkLabelsView.swift b/readeck/UI/BookmarkDetail/BookmarkLabelsView.swift index 18c39b0..f72dc3c 100644 --- a/readeck/UI/BookmarkDetail/BookmarkLabelsView.swift +++ b/readeck/UI/BookmarkDetail/BookmarkLabelsView.swift @@ -61,7 +61,6 @@ struct BookmarkLabelsView: View { selectedLabels: Set(viewModel.currentLabels), searchText: $viewModel.searchText, isLabelsLoading: viewModel.isInitialLoading, - availableLabelPages: viewModel.availableLabelPages, filteredLabels: viewModel.filteredLabels, onAddCustomTag: { Task { diff --git a/readeck/UI/BookmarkDetail/BookmarkLabelsViewModel.swift b/readeck/UI/BookmarkDetail/BookmarkLabelsViewModel.swift index 0a225b0..fa13119 100644 --- a/readeck/UI/BookmarkDetail/BookmarkLabelsViewModel.swift +++ b/readeck/UI/BookmarkDetail/BookmarkLabelsViewModel.swift @@ -10,46 +10,24 @@ class BookmarkLabelsViewModel { var isInitialLoading = false var errorMessage: String? var showErrorAlert = false - var currentLabels: [String] = [] { - didSet { - if oldValue != currentLabels { - calculatePages() - } - } - } + var currentLabels: [String] = [] var newLabelText = "" - var searchText = "" { - didSet { - if oldValue != searchText { - calculatePages() - } - } - } + var searchText = "" - var allLabels: [BookmarkLabel] = [] { - didSet { - if oldValue != allLabels { - calculatePages() - } - } - } - - var labelPages: [[BookmarkLabel]] = [] - - // Cached properties to avoid recomputation - private var _availableLabels: [BookmarkLabel] = [] - private var _filteredLabels: [BookmarkLabel] = [] + var allLabels: [BookmarkLabel] = [] var availableLabels: [BookmarkLabel] { - return _availableLabels + return allLabels.filter { !currentLabels.contains($0.name) } } var filteredLabels: [BookmarkLabel] { - return _filteredLabels + if searchText.isEmpty { + return availableLabels + } else { + return availableLabels.filter { $0.name.localizedCaseInsensitiveContains(searchText) } + } } - var availableLabelPages: [[BookmarkLabel]] = [] - init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared, initialLabels: [String] = []) { self.currentLabels = initialLabels @@ -70,8 +48,6 @@ class BookmarkLabelsViewModel { errorMessage = "failed to load labels" showErrorAlert = true } - - calculatePages() } @MainActor @@ -143,36 +119,4 @@ class BookmarkLabelsViewModel { func updateLabels(_ labels: [String]) { currentLabels = labels } - - private func calculatePages() { - let pageSize = Constants.Labels.pageSize - - // Update cached available labels - _availableLabels = allLabels.filter { !currentLabels.contains($0.name) } - - // Update cached filtered labels - if searchText.isEmpty { - _filteredLabels = _availableLabels - } else { - _filteredLabels = _availableLabels.filter { $0.name.localizedCaseInsensitiveContains(searchText) } - } - - // Calculate pages for all labels - if allLabels.count <= pageSize { - labelPages = [allLabels] - } else { - labelPages = stride(from: 0, to: allLabels.count, by: pageSize).map { - Array(allLabels[$0.. 4 { - scale = 4 - } - }, - DragGesture() - .onChanged { value in - if scale > 1 { - let newOffset = CGSize( - width: lastOffset.width + value.translation.width, - height: lastOffset.height + value.translation.height - ) - offset = newOffset - } else { - // Dismiss gesture when not zoomed - dragOffset = value.translation - let dragDistance = sqrt(pow(value.translation.width, 2) + pow(value.translation.height, 2)) - if dragDistance > 50 { - isDraggingToDismiss = true - } - } - } - .onEnded { value in - if scale <= 1 { - lastOffset = offset - let dragDistance = sqrt(pow(value.translation.width, 2) + pow(value.translation.height, 2)) - let velocity = sqrt(pow(value.velocity.width, 2) + pow(value.velocity.height, 2)) - - if dragDistance > 100 || velocity > 500 { - dismiss() - } else { - withAnimation(.spring()) { - dragOffset = .zero - isDraggingToDismiss = false - } - } - } else { - lastOffset = offset - } - } - ) - ) - .onTapGesture(count: 2) { - withAnimation(.spring()) { - if scale > 1 { - scale = 1 - offset = .zero - lastOffset = .zero - } else { - scale = 2 + CachedAsyncImage(url: URL(string: imageUrl)) + .scaledToFit() + .scaleEffect(scale) + .offset(offset) + .offset(dragOffset) + .opacity(isDraggingToDismiss ? 0.8 : 1.0) + .gesture( + SimultaneousGesture( + MagnificationGesture() + .onChanged { value in + let delta = value / lastScale + lastScale = value + scale = min(max(scale * delta, 1), 4) } + .onEnded { _ in + lastScale = 1.0 + if scale < 1 { + withAnimation(.spring()) { + scale = 1 + offset = .zero + } + } + if scale > 4 { + scale = 4 + } + }, + DragGesture() + .onChanged { value in + if scale > 1 { + let newOffset = CGSize( + width: lastOffset.width + value.translation.width, + height: lastOffset.height + value.translation.height + ) + offset = newOffset + } else { + // Dismiss gesture when not zoomed + dragOffset = value.translation + let dragDistance = sqrt(pow(value.translation.width, 2) + pow(value.translation.height, 2)) + if dragDistance > 50 { + isDraggingToDismiss = true + } + } + } + .onEnded { value in + if scale <= 1 { + lastOffset = offset + let dragDistance = sqrt(pow(value.translation.width, 2) + pow(value.translation.height, 2)) + let velocity = sqrt(pow(value.velocity.width, 2) + pow(value.velocity.height, 2)) + + if dragDistance > 100 || velocity > 500 { + dismiss() + } else { + withAnimation(.spring()) { + dragOffset = .zero + isDraggingToDismiss = false + } + } + } else { + lastOffset = offset + } + } + ) + ) + .onTapGesture(count: 2) { + withAnimation(.spring()) { + if scale > 1 { + scale = 1 + offset = .zero + lastOffset = .zero + } else { + scale = 2 } } - } placeholder: { - ProgressView() - .scaleEffect(1.5) - .foregroundColor(.white) - } + } } - } - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .navigationBarLeading) { - Button("Close") { - dismiss() + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + Button("Close") { + dismiss() + } + .foregroundColor(.white) } - .foregroundColor(.white) } } } } } - -#Preview { - ImageViewerView(imageUrl: "https://example.com/image.jpg") -} \ No newline at end of file diff --git a/readeck/UI/Bookmarks/BookmarkCardView.swift b/readeck/UI/Bookmarks/BookmarkCardView.swift index b74d85e..f5fe1f6 100644 --- a/readeck/UI/Bookmarks/BookmarkCardView.swift +++ b/readeck/UI/Bookmarks/BookmarkCardView.swift @@ -1,4 +1,5 @@ import SwiftUI +import Foundation import SafariServices extension View { @@ -12,35 +13,200 @@ extension View { } struct BookmarkCardView: View { - @Environment(\.colorScheme) var colorScheme let bookmark: Bookmark let currentState: BookmarkState + let layout: CardLayoutStyle + let pendingDelete: PendingDelete? let onArchive: (Bookmark) -> Void let onDelete: (Bookmark) -> Void let onToggleFavorite: (Bookmark) -> Void - let namespace: Namespace.ID? + let onUndoDelete: ((String) -> Void)? + + init( + bookmark: Bookmark, + currentState: BookmarkState, + layout: CardLayoutStyle = .magazine, + pendingDelete: PendingDelete? = nil, + onArchive: @escaping (Bookmark) -> Void, + onDelete: @escaping (Bookmark) -> Void, + onToggleFavorite: @escaping (Bookmark) -> Void, + onUndoDelete: ((String) -> Void)? = nil + ) { + self.bookmark = bookmark + self.currentState = currentState + self.layout = layout + self.pendingDelete = pendingDelete + self.onArchive = onArchive + self.onDelete = onDelete + self.onToggleFavorite = onToggleFavorite + self.onUndoDelete = onUndoDelete + } var body: some View { + ZStack(alignment: .bottom) { + Group { + switch layout { + case .compact: + compactLayoutView + case .magazine: + magazineLayoutView + case .natural: + naturalLayoutView + } + } + .opacity(pendingDelete != nil ? 0.4 : 1.0) + .animation(.easeInOut(duration: 0.2), value: pendingDelete != nil) + + // Undo button overlay + if let pendingDelete = pendingDelete { + VStack(spacing: 0) { + Spacer() + + // Undo button area - only when user interacts + HStack { + HStack(spacing: 6) { + Image(systemName: "trash") + .foregroundColor(.secondary) + .font(.caption2) + + Text("Deleting...") + .font(.caption2) + .foregroundColor(.secondary) + } + + Spacer() + + Button("Undo") { + onUndoDelete?(bookmark.id) + } + .font(.caption.weight(.medium)) + .foregroundColor(.blue) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(Color.blue.opacity(0.1)) + .clipShape(Capsule()) + .onTapGesture { + onUndoDelete?(bookmark.id) + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(Color(.systemBackground).opacity(0.95)) + } + .clipShape(RoundedRectangle(cornerRadius: layout == .compact ? 8 : 12)) + .shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1) + .transition(.opacity.combined(with: .move(edge: .bottom))) + } + + // Progress Bar am unteren Rand + if let pendingDelete = pendingDelete { + VStack(spacing: 0) { + Spacer() + + // Progress Bar + ProgressView(value: pendingDelete.progress, total: 1.0) + .progressViewStyle(LinearProgressViewStyle(tint: .red)) + .scaleEffect(x: 1, y: 1.5, anchor: .center) + .clipShape( + .rect( + bottomLeadingRadius: layout == .compact ? 8 : 12, + bottomTrailingRadius: layout == .compact ? 8 : 12 + ) + ) + } + } + } + .swipeActions(edge: .trailing, allowsFullSwipe: true) { + if pendingDelete == nil { + Button("Delete", role: .destructive) { + onDelete(bookmark) + } + .tint(.red) + } + } + .swipeActions(edge: .leading, allowsFullSwipe: true) { + if pendingDelete == nil { + Button { + onArchive(bookmark) + } label: { + if currentState == .archived { + Label("Restore", systemImage: "tray.and.arrow.up") + } else { + Label("Archive", systemImage: "archivebox") + } + } + .tint(currentState == .archived ? .blue : .orange) + + Button { + onToggleFavorite(bookmark) + } label: { + Label(bookmark.isMarked ? "Remove" : "Favorite", + systemImage: bookmark.isMarked ? "heart.slash" : "heart.fill") + } + .tint(bookmark.isMarked ? .gray : .pink) + } + } + } + + private var compactLayoutView: some View { + HStack(alignment: .top, spacing: 12) { + CachedAsyncImage(url: imageURL) + .aspectRatio(contentMode: .fill) + .frame(width: 80, height: 80) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + VStack(alignment: .leading, spacing: 4) { + Text(bookmark.title) + .font(.headline) + .fontWeight(.semibold) + .lineLimit(2) + .multilineTextAlignment(.leading) + + if !bookmark.description.isEmpty { + Text(bookmark.description) + .font(.subheadline) + .foregroundColor(.secondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + + HStack(spacing: 4) { + if !bookmark.siteName.isEmpty { + HStack(spacing: 2) { + Image(systemName: "globe") + Text(bookmark.siteName) + } + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + + if let readingTime = bookmark.readingTime, readingTime > 0 { + HStack(spacing: 2) { + Image(systemName: "clock") + Text("\(readingTime) min") + } + .font(.caption) + .foregroundColor(.secondary) + } + } + } + } + .padding(12) + .background(Color(R.color.bookmark_list_bg)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + private var magazineLayoutView: some View { VStack(alignment: .leading, spacing: 8) { ZStack(alignment: .bottomTrailing) { - AsyncImage(url: imageURL) { image in - image - .resizable() - .aspectRatio(contentMode: .fill) - .frame(height: 120) - } placeholder: { - - Image(R.image.placeholder.name) - .resizable() - .aspectRatio(contentMode: .fill) - .frame(height: 120) - } - .clipShape(RoundedRectangle(cornerRadius: 8)) - .if(namespace != nil) { view in - view.matchedGeometryEffect(id: "image-\(bookmark.id)", in: namespace!) - } + CachedAsyncImage(url: imageURL) + .aspectRatio(contentMode: .fill) + .frame(height: 140) + .clipShape(RoundedRectangle(cornerRadius: 8)) if bookmark.readProgress > 0 && bookmark.isArchived == false && bookmark.isMarked == false { ZStack { @@ -77,15 +243,12 @@ struct BookmarkCardView: View { VStack(alignment: .leading, spacing: 4) { HStack { - - // Published date if let publishedDate = formattedPublishedDate { HStack { Label(publishedDate, systemImage: "calendar") Spacer() } - - Spacer() // show spacer only if we have the published Date + Spacer() } if let readingTime = bookmark.readingTime, readingTime > 0 { @@ -107,41 +270,93 @@ struct BookmarkCardView: View { } .font(.caption) .foregroundColor(.secondary) - } .padding(.horizontal, 12) .padding(.bottom, 12) } .background(Color(R.color.bookmark_list_bg)) .clipShape(RoundedRectangle(cornerRadius: 12)) - .shadow(color: colorScheme == .light ? .black.opacity(0.1) : .white.opacity(0.1), radius: 2, x: 0, y: 1) - .swipeActions(edge: .trailing, allowsFullSwipe: true) { - Button("Delete", role: .destructive) { - onDelete(bookmark) - } - .tint(.red) - } - .swipeActions(edge: .leading, allowsFullSwipe: true) { - // Archive (left) - Button { - onArchive(bookmark) - } label: { - if currentState == .archived { - Label("Restore", systemImage: "tray.and.arrow.up") - } else { - Label("Archive", systemImage: "archivebox") + .shadow(color: Color.black.opacity(0.1), radius: 8, x: 0, y: 4) + .shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1) + } + + private var naturalLayoutView: some View { + VStack(alignment: .leading, spacing: 8) { + ZStack(alignment: .bottomTrailing) { + CachedAsyncImage(url: imageURL) + .aspectRatio(contentMode: .fit) + .frame(minHeight: 180) + .clipShape(RoundedRectangle(cornerRadius: 8)) + + if bookmark.readProgress > 0 && bookmark.isArchived == false && bookmark.isMarked == false { + ZStack { + Circle() + .fill(Color(.systemBackground)) + .frame(width: 36, height: 36) + Circle() + .stroke(Color.gray.opacity(0.2), lineWidth: 4) + .frame(width: 32, height: 32) + Circle() + .trim(from: 0, to: CGFloat(bookmark.readProgress) / 100) + .stroke(Color.accentColor, style: StrokeStyle(lineWidth: 4, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .frame(width: 32, height: 32) + HStack(alignment: .firstTextBaseline, spacing: 0) { + Text("\(bookmark.readProgress)") + .font(.caption2) + .bold() + Text("%") + .font(.system(size: 8)) + .baselineOffset(2) + } + } + .padding(8) } } - .tint(currentState == .archived ? .blue : .orange) - Button { - onToggleFavorite(bookmark) - } label: { - Label(bookmark.isMarked ? "Remove" : "Favorite", - systemImage: bookmark.isMarked ? "heart.slash" : "heart.fill") + VStack(alignment: .leading, spacing: 4) { + Text(bookmark.title) + .font(.headline) + .fontWeight(.semibold) + .lineLimit(2) + .multilineTextAlignment(.leading) + + VStack(alignment: .leading, spacing: 4) { + HStack { + if let publishedDate = formattedPublishedDate { + HStack { + Label(publishedDate, systemImage: "calendar") + Spacer() + } + Spacer() + } + + if let readingTime = bookmark.readingTime, readingTime > 0 { + Label("\(readingTime) min", systemImage: "clock") + } + } + + HStack { + if !bookmark.siteName.isEmpty { + Label(bookmark.siteName, systemImage: "globe") + } + } + HStack { + Label((URLUtil.extractDomain(from: bookmark.url) ?? "Original Site") + " open", systemImage: "safari") + .onTapGesture { + SafariUtil.openInSafari(url: bookmark.url) + } + } + } + .font(.caption) + .foregroundColor(.secondary) } - .tint(bookmark.isMarked ? .gray : .pink) + .padding(.horizontal, 12) + .padding(.bottom, 12) } + .background(Color(R.color.bookmark_list_bg)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: Color.black.opacity(0.08), radius: 6, x: 0, y: 3) } // MARK: - Computed Properties @@ -156,13 +371,10 @@ struct BookmarkCardView: View { } let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" - formatter.timeZone = TimeZone(abbreviation: "UTC") - formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'" guard let date = formatter.date(from: published) else { - // Fallback without milliseconds - formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss'Z'" + formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" guard let fallbackDate = formatter.date(from: published) else { return nil } @@ -173,18 +385,19 @@ struct BookmarkCardView: View { } private func formatDate(_ date: Date) -> String { - let now = Date() let calendar = Calendar.current + let now = Date() // Today - if calendar.isDateInToday(date) { + if calendar.isDate(date, inSameDayAs: now) { let formatter = DateFormatter() formatter.timeStyle = .short return "Today, \(formatter.string(from: date))" } // Yesterday - if calendar.isDateInYesterday(date) { + if let yesterday = calendar.date(byAdding: .day, value: -1, to: now), + calendar.isDate(date, inSameDayAs: yesterday) { let formatter = DateFormatter() formatter.timeStyle = .short return "Yesterday, \(formatter.string(from: date))" @@ -211,13 +424,8 @@ struct BookmarkCardView: View { } private var imageURL: URL? { - // Prioritize image, then thumbnail, then 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 } @@ -229,11 +437,9 @@ struct IconBadge: View { var body: some View { Image(systemName: systemName) - .font(.caption2) - .padding(6) - .background(color.opacity(0.2)) - .foregroundColor(color) + .frame(width: 20, height: 20) + .background(color) + .foregroundColor(.white) .clipShape(Circle()) } -} - +} \ No newline at end of file diff --git a/readeck/UI/Bookmarks/BookmarksView.swift b/readeck/UI/Bookmarks/BookmarksView.swift index 628e38c..d75860e 100644 --- a/readeck/UI/Bookmarks/BookmarksView.swift +++ b/readeck/UI/Bookmarks/BookmarksView.swift @@ -4,8 +4,6 @@ import SwiftUI struct BookmarksView: View { - @Namespace private var namespace - // MARK: States @State private var viewModel: BookmarksViewModel @@ -14,7 +12,6 @@ struct BookmarksView: View { @State private var showingAddBookmarkFromShare = false @State private var shareURL = "" @State private var shareTitle = "" - @State private var bookmarkToDelete: Bookmark? = nil let state: BookmarkState let type: [BookmarkType] @@ -39,14 +36,16 @@ struct BookmarksView: View { var body: some View { ZStack { - if shouldShowCenteredState { + if viewModel.isInitialLoading && (viewModel.bookmarks?.bookmarks.isEmpty != false) { + skeletonLoadingView + } else if shouldShowCenteredState { centeredStateView } else { bookmarksList } // FAB Button - only show for "Unread" and when not in error/loading state - if (state == .unread || state == .all) && !shouldShowCenteredState { + if (state == .unread || state == .all) && !shouldShowCenteredState && !viewModel.isInitialLoading { fabButton } } @@ -56,8 +55,7 @@ struct BookmarksView: View { set: { selectedBookmarkId = $0 } ) ) { bookmarkId in - BookmarkDetailView(bookmarkId: bookmarkId, namespace: namespace) - .navigationTransition(.zoom(sourceID: bookmarkId, in: namespace)) + BookmarkDetailView(bookmarkId: bookmarkId) } .sheet(isPresented: $showingAddBookmark) { AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle) @@ -68,18 +66,6 @@ struct BookmarksView: View { AddBookmarkView(prefilledURL: shareURL, prefilledTitle: shareTitle) } ) - .alert(item: $bookmarkToDelete) { bookmark in - Alert( - title: Text("Delete Bookmark"), - message: Text("Are you sure you want to delete this bookmark? This action cannot be undone."), - primaryButton: .destructive(Text("Delete")) { - Task { - await viewModel.deleteBookmark(bookmark: bookmark) - } - }, - secondaryButton: .cancel() - ) - } .onAppear { Task { await viewModel.loadBookmarks(state: state, type: type, tag: tag) @@ -179,6 +165,11 @@ struct BookmarksView: View { List { ForEach(viewModel.bookmarks?.bookmarks ?? [], id: \.id) { bookmark in Button(action: { + // Don't navigate to detail if bookmark is pending deletion + if viewModel.pendingDeletes[bookmark.id] != nil { + return + } + if UIDevice.isPhone { selectedBookmarkId = bookmark.id } else { @@ -195,20 +186,24 @@ struct BookmarksView: View { BookmarkCardView( bookmark: bookmark, currentState: state, + layout: viewModel.cardLayoutStyle, + pendingDelete: viewModel.pendingDeletes[bookmark.id], onArchive: { bookmark in Task { await viewModel.toggleArchive(bookmark: bookmark) } }, onDelete: { bookmark in - bookmarkToDelete = bookmark + viewModel.deleteBookmarkWithUndo(bookmark: bookmark) }, onToggleFavorite: { bookmark in Task { await viewModel.toggleFavorite(bookmark: bookmark) } }, - namespace: namespace + onUndoDelete: { bookmarkId in + viewModel.undoDelete(bookmarkId: bookmarkId) + } ) .onAppear { if bookmark.id == viewModel.bookmarks?.bookmarks.last?.id { @@ -219,10 +214,14 @@ struct BookmarksView: View { } } .buttonStyle(PlainButtonStyle()) - .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) + .listRowInsets(EdgeInsets( + top: viewModel.cardLayoutStyle == .compact ? 8 : 12, + leading: 16, + bottom: viewModel.cardLayoutStyle == .compact ? 8 : 12, + trailing: 16 + )) .listRowSeparator(.hidden) .listRowBackground(Color(R.color.bookmark_list_bg)) - .matchedTransitionSource(id: bookmark.id, in: namespace) } // Show loading indicator for pagination @@ -256,6 +255,25 @@ struct BookmarksView: View { } } + @ViewBuilder + private var skeletonLoadingView: some View { + ScrollView { + SkeletonLoadingView(layout: viewModel.cardLayoutStyle) + .padding( + EdgeInsets( + top: viewModel.cardLayoutStyle == .compact ? 8 : 12, + leading: 16, + bottom: viewModel.cardLayoutStyle == .compact ? 8 : 12, + trailing: 16 + ) + ) + } + .background(Color(R.color.bookmark_list_bg)) + .refreshable { + await viewModel.refreshBookmarks() + } + } + @ViewBuilder private var fabButton: some View { VStack { diff --git a/readeck/UI/Bookmarks/BookmarksViewModel.swift b/readeck/UI/Bookmarks/BookmarksViewModel.swift index e365596..8e9a4ca 100644 --- a/readeck/UI/Bookmarks/BookmarksViewModel.swift +++ b/readeck/UI/Bookmarks/BookmarksViewModel.swift @@ -7,21 +7,27 @@ class BookmarksViewModel { private let getBooksmarksUseCase: PGetBookmarksUseCase private let updateBookmarkUseCase: PUpdateBookmarkUseCase private let deleteBookmarkUseCase: PDeleteBookmarkUseCase + private let loadCardLayoutUseCase: PLoadCardLayoutUseCase var bookmarks: BookmarksPage? var isLoading = false + var isInitialLoading = true var errorMessage: String? var currentState: BookmarkState = .unread var currentType = [BookmarkType.article] var currentTag: String? = nil + var cardLayoutStyle: CardLayoutStyle = .magazine var showingAddBookmarkFromShare = false var shareURL = "" var shareTitle = "" + // Undo delete functionality + var pendingDeletes: [String: PendingDelete] = [:] // bookmarkId -> PendingDelete + private var cancellables = Set() - private var limit = 20 + private var limit = 50 private var offset = 0 private var hasMoreData = true private var searchWorkItem: DispatchWorkItem? @@ -36,13 +42,31 @@ class BookmarksViewModel { getBooksmarksUseCase = factory.makeGetBookmarksUseCase() updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase() deleteBookmarkUseCase = factory.makeDeleteBookmarkUseCase() + loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase() setupNotificationObserver() + + Task { + await loadCardLayout() + } } private func setupNotificationObserver() { + // Listen for card layout changes NotificationCenter.default - .publisher(for: NSNotification.Name("AddBookmarkFromShare")) + .publisher(for: .cardLayoutChanged) + .sink { notification in + if let layout = notification.object as? CardLayoutStyle { + Task { @MainActor in + self.cardLayoutStyle = layout + } + } + } + .store(in: &cancellables) + + // Listen for + NotificationCenter.default + .publisher(for: .addBookmarkFromShare) .sink { [weak self] notification in self?.handleShareNotification(notification) } @@ -105,6 +129,7 @@ class BookmarksViewModel { } isLoading = false + isInitialLoading = false } @MainActor @@ -168,14 +193,94 @@ class BookmarksViewModel { } @MainActor - func deleteBookmark(bookmark: Bookmark) async { + func deleteBookmarkWithUndo(bookmark: Bookmark) { + // Don't remove from UI immediately - just mark as pending + let pendingDelete = PendingDelete(bookmark: bookmark) + pendingDeletes[bookmark.id] = pendingDelete + + // Start countdown timer for this specific delete + startDeleteCountdown(for: bookmark.id) + + // Schedule actual delete after 3 seconds + let deleteTask = Task { + try? await Task.sleep(nanoseconds: 3_000_000_000) // 3 seconds + + // Check if not cancelled and still pending + if !Task.isCancelled, pendingDeletes[bookmark.id] != nil { + await executeDelete(bookmark: bookmark) + await MainActor.run { + // Clean up + pendingDeletes[bookmark.id]?.timer?.invalidate() + pendingDeletes.removeValue(forKey: bookmark.id) + } + } + } + + // Store the task in the pending delete + pendingDeletes[bookmark.id]?.deleteTask = deleteTask + } + + @MainActor + func undoDelete(bookmarkId: String) { + guard let pendingDelete = pendingDeletes[bookmarkId] else { return } + + // Cancel the delete task and timer + pendingDelete.deleteTask?.cancel() + pendingDelete.timer?.invalidate() + + // Remove from pending deletes + pendingDeletes.removeValue(forKey: bookmarkId) + } + + private func startDeleteCountdown(for bookmarkId: String) { + let timer = Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { [weak self] timer in + Task { @MainActor in + guard let self = self, + let pendingDelete = self.pendingDeletes[bookmarkId] else { + timer.invalidate() + return + } + + pendingDelete.progress += 1.0 / 30.0 // 3 seconds / 0.1 interval = 30 steps + + if pendingDelete.progress >= 1.0 { + timer.invalidate() + } + } + } + + pendingDeletes[bookmarkId]?.timer = timer + } + + private func executeDelete(bookmark: Bookmark) async { do { try await deleteBookmarkUseCase.execute(bookmarkId: bookmark.id) - bookmarks?.bookmarks.removeAll { $0.id == bookmark.id } - } catch { - errorMessage = "Error deleting bookmark" - await loadBookmarks(state: currentState) + // If delete fails, restore the bookmark + await MainActor.run { + errorMessage = "Error deleting bookmark" + if var currentBookmarks = bookmarks?.bookmarks { + currentBookmarks.insert(bookmark, at: 0) + bookmarks?.bookmarks = currentBookmarks + } + } } } + + @MainActor + private func loadCardLayout() async { + cardLayoutStyle = await loadCardLayoutUseCase.execute() + } +} + +class PendingDelete: Identifiable, ObservableObject { + let id = UUID() + let bookmark: Bookmark + @Published var progress: Double = 0.0 + var timer: Timer? + var deleteTask: Task? + + init(bookmark: Bookmark) { + self.bookmark = bookmark + } } diff --git a/readeck/UI/Components/CachedAsyncImage.swift b/readeck/UI/Components/CachedAsyncImage.swift new file mode 100644 index 0000000..3d287d1 --- /dev/null +++ b/readeck/UI/Components/CachedAsyncImage.swift @@ -0,0 +1,26 @@ +import SwiftUI +import Kingfisher + +struct CachedAsyncImage: View { + let url: URL? + + init(url: URL?) { + self.url = url + } + + var body: some View { + if let url { + KFImage(url) + .placeholder { + Color.gray.opacity(0.3) + } + .fade(duration: 0.25) + .resizable() + .frame(maxWidth: .infinity) + } else { + Image("placeholder") + .resizable() + .scaledToFill() + } + } +} diff --git a/readeck/UI/Components/Constants.swift b/readeck/UI/Components/Constants.swift index e7ecd3a..09b31f5 100644 --- a/readeck/UI/Components/Constants.swift +++ b/readeck/UI/Components/Constants.swift @@ -12,7 +12,5 @@ import Foundation struct Constants { - struct Labels { - static let pageSize = 12 - } + // Empty for now - can be used for other constants in the future } diff --git a/readeck/UI/Components/SkeletonLoadingView.swift b/readeck/UI/Components/SkeletonLoadingView.swift new file mode 100644 index 0000000..a638297 --- /dev/null +++ b/readeck/UI/Components/SkeletonLoadingView.swift @@ -0,0 +1,176 @@ +import SwiftUI + +struct SkeletonLoadingView: View { + let layout: CardLayoutStyle + @State private var animateGradient = false + + var body: some View { + LazyVStack(spacing: layout == .compact ? 8 : 12) { + ForEach(0..<6, id: \.self) { _ in + skeletonCard + } + } + .onAppear { + withAnimation(Animation.linear(duration: 1.5).repeatForever(autoreverses: false)) { + animateGradient = true + } + } + } + + @ViewBuilder + private var skeletonCard: some View { + switch layout { + case .compact: + compactSkeletonCard + case .magazine: + magazineSkeletonCard + case .natural: + naturalSkeletonCard + } + } + + private var compactSkeletonCard: some View { + HStack(alignment: .top, spacing: 12) { + // Image placeholder + RoundedRectangle(cornerRadius: 8) + .fill(shimmerGradient) + .frame(width: 80, height: 80) + + VStack(alignment: .leading, spacing: 4) { + // Title placeholder + RoundedRectangle(cornerRadius: 4) + .fill(shimmerGradient) + .frame(height: 16) + + RoundedRectangle(cornerRadius: 4) + .fill(shimmerGradient) + .frame(width: 180, height: 16) + + // Description placeholder + RoundedRectangle(cornerRadius: 4) + .fill(shimmerGradient) + .frame(height: 14) + + RoundedRectangle(cornerRadius: 4) + .fill(shimmerGradient) + .frame(width: 120, height: 14) + + Spacer() + + // Bottom info placeholder + HStack { + RoundedRectangle(cornerRadius: 4) + .fill(shimmerGradient) + .frame(width: 80, height: 12) + + Spacer() + + RoundedRectangle(cornerRadius: 4) + .fill(shimmerGradient) + .frame(width: 50, height: 12) + } + } + } + .padding(12) + .background(Color(R.color.bookmark_list_bg)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + private var magazineSkeletonCard: some View { + VStack(alignment: .leading, spacing: 8) { + // Image placeholder + RoundedRectangle(cornerRadius: 8) + .fill(shimmerGradient) + .frame(height: 140) + + VStack(alignment: .leading, spacing: 4) { + // Title placeholder + RoundedRectangle(cornerRadius: 4) + .fill(shimmerGradient) + .frame(height: 16) + + RoundedRectangle(cornerRadius: 4) + .fill(shimmerGradient) + .frame(width: 200, height: 16) + + // Info placeholders + HStack { + RoundedRectangle(cornerRadius: 4) + .fill(shimmerGradient) + .frame(width: 80, height: 12) + + Spacer() + + RoundedRectangle(cornerRadius: 4) + .fill(shimmerGradient) + .frame(width: 60, height: 12) + } + .padding(.top, 4) + } + .padding(.horizontal, 12) + .padding(.bottom, 12) + } + .background(Color(R.color.bookmark_list_bg)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: Color.black.opacity(0.1), radius: 8, x: 0, y: 4) + .shadow(color: Color.black.opacity(0.05), radius: 2, x: 0, y: 1) + } + + private var naturalSkeletonCard: some View { + VStack(alignment: .leading, spacing: 8) { + // Image placeholder + RoundedRectangle(cornerRadius: 8) + .fill(shimmerGradient) + .frame(minHeight: 180) + + VStack(alignment: .leading, spacing: 4) { + // Title placeholder + RoundedRectangle(cornerRadius: 4) + .fill(shimmerGradient) + .frame(height: 16) + + RoundedRectangle(cornerRadius: 4) + .fill(shimmerGradient) + .frame(width: 220, height: 16) + + // Info placeholders + HStack { + RoundedRectangle(cornerRadius: 4) + .fill(shimmerGradient) + .frame(width: 90, height: 12) + + Spacer() + + RoundedRectangle(cornerRadius: 4) + .fill(shimmerGradient) + .frame(width: 70, height: 12) + } + .padding(.top, 4) + } + .padding(.horizontal, 12) + .padding(.bottom, 12) + } + .background(Color(R.color.bookmark_list_bg)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .shadow(color: Color.black.opacity(0.08), radius: 6, x: 0, y: 3) + } + + private var shimmerGradient: LinearGradient { + LinearGradient( + colors: [ + Color.gray.opacity(0.3), + Color.gray.opacity(0.1), + Color.gray.opacity(0.3) + ], + startPoint: animateGradient ? .topLeading : .topTrailing, + endPoint: animateGradient ? .bottomTrailing : .bottomLeading + ) + } +} + +#Preview { + ScrollView { + SkeletonLoadingView(layout: .magazine) + .padding() + } +} \ No newline at end of file diff --git a/readeck/UI/Components/TagManagementView.swift b/readeck/UI/Components/TagManagementView.swift index 015e02c..3d0568d 100644 --- a/readeck/UI/Components/TagManagementView.swift +++ b/readeck/UI/Components/TagManagementView.swift @@ -1,5 +1,61 @@ import SwiftUI +struct FlowLayout: Layout { + var spacing: CGFloat = 8 + + func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize { + let result = FlowResult( + in: proposal.replacingUnspecifiedDimensions().width, + subviews: subviews, + spacing: spacing + ) + return result.bounds + } + + func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) { + let result = FlowResult( + in: bounds.width, + subviews: subviews, + spacing: spacing + ) + + for (index, subview) in subviews.enumerated() { + subview.place(at: CGPoint( + x: bounds.minX + result.frames[index].minX, + y: bounds.minY + result.frames[index].minY + ), proposal: ProposedViewSize(result.frames[index].size)) + } + } +} + +struct FlowResult { + var frames: [CGRect] = [] + var bounds: CGSize = .zero + + init(in maxWidth: CGFloat, subviews: LayoutSubviews, spacing: CGFloat) { + var x: CGFloat = 0 + var y: CGFloat = 0 + var lineHeight: CGFloat = 0 + + for subview in subviews { + let size = subview.sizeThatFits(.unspecified) + + if x + size.width > maxWidth && x > 0 { + x = 0 + y += lineHeight + spacing + lineHeight = 0 + } + + frames.append(CGRect(x: x, y: y, width: size.width, height: size.height)) + lineHeight = max(lineHeight, size.height) + x += size.width + spacing + bounds.width = max(bounds.width, x - spacing) + } + + bounds.height = y + lineHeight + } +} + enum AddBookmarkFieldFocus { case url case labels @@ -27,7 +83,6 @@ struct TagManagementView: View { let selectedLabelsSet: Set let searchText: Binding let isLabelsLoading: Bool - let availableLabelPages: [[BookmarkLabel]] let filteredLabels: [BookmarkLabel] let searchFieldFocus: FocusState.Binding? @@ -44,7 +99,6 @@ struct TagManagementView: View { selectedLabels: Set, searchText: Binding, isLabelsLoading: Bool, - availableLabelPages: [[BookmarkLabel]], filteredLabels: [BookmarkLabel], searchFieldFocus: FocusState.Binding? = nil, onAddCustomTag: @escaping () -> Void, @@ -55,7 +109,6 @@ struct TagManagementView: View { self.selectedLabelsSet = selectedLabels self.searchText = searchText self.isLabelsLoading = isLabelsLoading - self.availableLabelPages = availableLabelPages self.filteredLabels = filteredLabels self.searchFieldFocus = searchFieldFocus self.onAddCustomTag = onAddCustomTag @@ -138,7 +191,7 @@ struct TagManagementView: View { .scaleEffect(0.8) .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, 20) - } else if availableLabelPages.isEmpty { + } else if allLabels.isEmpty { VStack { Image(systemName: "checkmark.circle.fill") .font(.system(size: 24)) @@ -150,7 +203,7 @@ struct TagManagementView: View { .frame(maxWidth: .infinity) .padding(.vertical, 20) } else { - labelsTabView + labelsScrollView } } .padding(.top, 8) @@ -158,28 +211,47 @@ struct TagManagementView: View { } @ViewBuilder - private var labelsTabView: some View { - TabView { - ForEach(Array(availableLabelPages.enumerated()), id: \.offset) { pageIndex, labelsPage in - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) { - ForEach(labelsPage, id: \.id) { label in - UnifiedLabelChip( - label: label.name, - isSelected: selectedLabelsSet.contains(label.name), - isRemovable: false, - onTap: { - onToggleLabel(label.name) - } - ) + private var labelsScrollView: some View { + ScrollView(.horizontal, showsIndicators: false) { + VStack(alignment: .leading, spacing: 8) { + ForEach(chunkedLabels, id: \.self) { rowLabels in + HStack(alignment: .top, spacing: 8) { + ForEach(rowLabels, id: \.id) { label in + UnifiedLabelChip( + label: label.name, + isSelected: false, + isRemovable: false, + onTap: { + onToggleLabel(label.name) + } + ) + } + Spacer() } } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .padding(.horizontal) } + .padding(.horizontal) } - .tabViewStyle(.page(indexDisplayMode: availableLabelPages.count > 1 ? .automatic : .never)) - .frame(height: 180) - .padding(.top, 10) + .frame(height: calculateMaxHeight()) + } + + private var chunkedLabels: [[BookmarkLabel]] { + let maxRows = 3 + let labelsPerRow = max(1, availableUnselectedLabels.count / maxRows + (availableUnselectedLabels.count % maxRows > 0 ? 1 : 0)) + return availableUnselectedLabels.chunked(into: labelsPerRow) + } + + private var availableUnselectedLabels: [BookmarkLabel] { + let labelsToShow = searchText.wrappedValue.isEmpty ? allLabels : filteredLabels + return labelsToShow.filter { !selectedLabelsSet.contains($0.name) } + } + + private func calculateMaxHeight() -> CGFloat { + // Berechne Höhe für maximal 3 Reihen + let rowHeight: CGFloat = 32 // Höhe eines Labels + let spacing: CGFloat = 8 + let maxRows: CGFloat = 3 + return (rowHeight * maxRows) + (spacing * (maxRows - 1)) } @ViewBuilder @@ -190,11 +262,11 @@ struct TagManagementView: View { .font(.subheadline) .fontWeight(.medium) - LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) { + FlowLayout(spacing: 8) { ForEach(selectedLabelsSet.sorted(), id: \.self) { label in UnifiedLabelChip( label: label, - isSelected: false, + isSelected: true, isRemovable: true, onTap: { // No action for selected labels @@ -210,3 +282,11 @@ struct TagManagementView: View { } } } + +extension Array { + func chunked(into size: Int) -> [[Element]] { + return stride(from: 0, to: count, by: size).map { + Array(self[$0.. Void + + var body: some View { + HStack(spacing: 12) { + Image(systemName: "trash") + .foregroundColor(.white) + .font(.system(size: 16, weight: .medium)) + + VStack(alignment: .leading, spacing: 4) { + Text("Bookmark deleted") + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.white) + + Text(bookmarkTitle) + .font(.caption) + .foregroundColor(.white.opacity(0.8)) + .lineLimit(1) + .truncationMode(.tail) + } + + Spacer() + + Button("Undo") { + onUndo() + } + .font(.subheadline) + .fontWeight(.semibold) + .foregroundColor(.white) + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.white.opacity(0.2)) + .clipShape(Capsule()) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background(Color.black.opacity(0.85)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .overlay( + // Progress bar at bottom + VStack { + Spacer() + ProgressView(value: progress) + .progressViewStyle(LinearProgressViewStyle(tint: .white.opacity(0.8))) + .scaleEffect(y: 0.5) + } + ) + .shadow(color: .black.opacity(0.3), radius: 8, x: 0, y: 4) + } +} + +#Preview { + VStack { + Spacer() + UndoToastView( + bookmarkTitle: "How to Build Great Products", + progress: 0.6, + onUndo: {} + ) + .padding() + } + .background(Color.gray.opacity(0.3)) +} \ No newline at end of file diff --git a/readeck/UI/Factory/DefaultUseCaseFactory.swift b/readeck/UI/Factory/DefaultUseCaseFactory.swift index f09011d..c107df5 100644 --- a/readeck/UI/Factory/DefaultUseCaseFactory.swift +++ b/readeck/UI/Factory/DefaultUseCaseFactory.swift @@ -18,6 +18,8 @@ protocol UseCaseFactory { func makeGetLabelsUseCase() -> PGetLabelsUseCase func makeAddTextToSpeechQueueUseCase() -> PAddTextToSpeechQueueUseCase func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase + func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase + func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase } @@ -102,4 +104,12 @@ class DefaultUseCaseFactory: UseCaseFactory { func makeOfflineBookmarkSyncUseCase() -> POfflineBookmarkSyncUseCase { return OfflineBookmarkSyncUseCase() } + + func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase { + return LoadCardLayoutUseCase(settingsRepository: settingsRepository) + } + + func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase { + return SaveCardLayoutUseCase(settingsRepository: settingsRepository) + } } diff --git a/readeck/UI/Factory/MockUseCaseFactory.swift b/readeck/UI/Factory/MockUseCaseFactory.swift index f2d6f4b..c752f2c 100644 --- a/readeck/UI/Factory/MockUseCaseFactory.swift +++ b/readeck/UI/Factory/MockUseCaseFactory.swift @@ -77,6 +77,13 @@ class MockUseCaseFactory: UseCaseFactory { MockAddTextToSpeechQueueUseCase() } + func makeLoadCardLayoutUseCase() -> PLoadCardLayoutUseCase { + MockLoadCardLayoutUseCase() + } + + func makeSaveCardLayoutUseCase() -> PSaveCardLayoutUseCase { + MockSaveCardLayoutUseCase() + } } @@ -204,6 +211,18 @@ class MockOfflineBookmarkSyncUseCase: POfflineBookmarkSyncUseCase { } } +class MockLoadCardLayoutUseCase: PLoadCardLayoutUseCase { + func execute() async -> CardLayoutStyle { + return .magazine + } +} + +class MockSaveCardLayoutUseCase: PSaveCardLayoutUseCase { + func execute(layout: CardLayoutStyle) async { + // Mock implementation - do nothing + } +} + extension Bookmark { static let mock: Bookmark = .init( id: "123", title: "title", url: "https://example.com", href: "https://example.com", description: "description", authors: ["Tom"], created: "", published: "", updated: "", siteName: "example.com", site: "https://example.com", readingTime: 2, wordCount: 20, hasArticle: true, isArchived: false, isDeleted: false, isMarked: true, labels: ["Test"], lang: "EN", loaded: false, readProgress: 0, documentType: "", state: 0, textDirection: "ltr", type: "", resources: .init(article: nil, icon: nil, image: nil, log: nil, props: nil, thumbnail: nil) diff --git a/readeck/UI/Search/SearchBookmarksView.swift b/readeck/UI/Search/SearchBookmarksView.swift index d35996f..92d1656 100644 --- a/readeck/UI/Search/SearchBookmarksView.swift +++ b/readeck/UI/Search/SearchBookmarksView.swift @@ -61,7 +61,7 @@ struct SearchBookmarksView: View { } } }) { - BookmarkCardView(bookmark: bookmark, currentState: .all, onArchive: {_ in }, onDelete: {_ in }, onToggleFavorite: {_ in }, namespace: namespace) + BookmarkCardView(bookmark: bookmark, currentState: .all, onArchive: {_ in }, onDelete: {_ in }, onToggleFavorite: {_ in }) } .buttonStyle(PlainButtonStyle()) .listRowInsets(EdgeInsets(top: 8, leading: 16, bottom: 8, trailing: 16)) @@ -91,8 +91,7 @@ struct SearchBookmarksView: View { set: { selectedBookmarkId = $0 } ) ) { bookmarkId in - BookmarkDetailView(bookmarkId: bookmarkId, namespace: namespace) - .navigationTransition(.zoom(sourceID: bookmarkId, in: namespace)) + BookmarkDetailView(bookmarkId: bookmarkId) } .onAppear { if isFirstAppearance { diff --git a/readeck/UI/Settings/AppearanceSettingsView.swift b/readeck/UI/Settings/AppearanceSettingsView.swift new file mode 100644 index 0000000..3d77e55 --- /dev/null +++ b/readeck/UI/Settings/AppearanceSettingsView.swift @@ -0,0 +1,221 @@ +import SwiftUI + +struct AppearanceSettingsView: View { + @State private var selectedCardLayout: CardLayoutStyle = .magazine + @State private var selectedTheme: Theme = .system + + private let loadCardLayoutUseCase: PLoadCardLayoutUseCase + private let saveCardLayoutUseCase: PSaveCardLayoutUseCase + + init(factory: UseCaseFactory = DefaultUseCaseFactory.shared) { + self.loadCardLayoutUseCase = factory.makeLoadCardLayoutUseCase() + self.saveCardLayoutUseCase = factory.makeSaveCardLayoutUseCase() + } + + var body: some View { + VStack(spacing: 20) { + SectionHeader(title: "Appearance", icon: "paintbrush") + .padding(.bottom, 4) + + // Theme Section + VStack(alignment: .leading, spacing: 12) { + Text("Theme") + .font(.headline) + Picker("Theme", selection: $selectedTheme) { + ForEach(Theme.allCases, id: \.self) { theme in + Text(theme.displayName).tag(theme) + } + } + .pickerStyle(.segmented) + .onChange(of: selectedTheme) { + saveThemeSettings() + } + } + + Divider() + + // Card Layout Section + VStack(alignment: .leading, spacing: 12) { + Text("Card Layout") + .font(.headline) + + VStack(spacing: 16) { + ForEach(CardLayoutStyle.allCases, id: \.self) { layout in + CardLayoutPreview( + layout: layout, + isSelected: selectedCardLayout == layout + ) { + selectedCardLayout = layout + saveCardLayoutSettings() + } + } + } + } + } + .onAppear { + loadSettings() + } + } + + private func loadSettings() { + // Load theme setting + let themeString = UserDefaults.standard.string(forKey: "selectedTheme") ?? "system" + selectedTheme = Theme(rawValue: themeString) ?? .system + + // Load card layout setting + Task { + selectedCardLayout = await loadCardLayoutUseCase.execute() + } + } + + private func saveThemeSettings() { + UserDefaults.standard.set(selectedTheme.rawValue, forKey: "selectedTheme") + } + + private func saveCardLayoutSettings() { + Task { + await saveCardLayoutUseCase.execute(layout: selectedCardLayout) + // Notify other parts of the app about the change + await MainActor.run { + NotificationCenter.default.post(name: .cardLayoutChanged, object: selectedCardLayout) + } + } + } +} + + +struct CardLayoutPreview: View { + let layout: CardLayoutStyle + let isSelected: Bool + let onSelect: () -> Void + + var body: some View { + Button(action: onSelect) { + HStack(spacing: 12) { + // Visual Preview + switch layout { + case .compact: + // Compact: Small image on left, content on right + HStack(spacing: 8) { + RoundedRectangle(cornerRadius: 4) + .fill(Color.blue.opacity(0.6)) + .frame(width: 24, height: 24) + + VStack(alignment: .leading, spacing: 2) { + RoundedRectangle(cornerRadius: 2) + .fill(Color.primary.opacity(0.8)) + .frame(height: 6) + .frame(maxWidth: .infinity) + RoundedRectangle(cornerRadius: 2) + .fill(Color.primary.opacity(0.6)) + .frame(height: 4) + .frame(maxWidth: 60) + RoundedRectangle(cornerRadius: 2) + .fill(Color.primary.opacity(0.4)) + .frame(height: 4) + .frame(maxWidth: 40) + } + } + .padding(8) + .background(Color.gray.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .frame(width: 80, height: 50) + + case .magazine: + VStack(spacing: 4) { + RoundedRectangle(cornerRadius: 6) + .fill(Color.blue.opacity(0.6)) + .frame(height: 24) + + VStack(alignment: .leading, spacing: 2) { + RoundedRectangle(cornerRadius: 2) + .fill(Color.primary.opacity(0.8)) + .frame(height: 5) + RoundedRectangle(cornerRadius: 2) + .fill(Color.primary.opacity(0.6)) + .frame(height: 4) + .frame(maxWidth: 40) + + Text("Fixed 140px") + .font(.system(size: 7)) + .foregroundColor(.secondary) + .padding(.top, 1) + } + .padding(.horizontal, 4) + } + .padding(6) + .background(Color.gray.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .frame(width: 80, height: 65) + .shadow(color: .black.opacity(0.1), radius: 2, x: 0, y: 1) + + case .natural: + VStack(spacing: 3) { + RoundedRectangle(cornerRadius: 6) + .fill(Color.blue.opacity(0.6)) + .frame(height: 38) + + VStack(alignment: .leading, spacing: 2) { + RoundedRectangle(cornerRadius: 2) + .fill(Color.primary.opacity(0.8)) + .frame(height: 5) + RoundedRectangle(cornerRadius: 2) + .fill(Color.primary.opacity(0.6)) + .frame(height: 4) + .frame(maxWidth: 35) + + Text("Original ratio") + .font(.system(size: 7)) + .foregroundColor(.secondary) + .padding(.top, 1) + } + .padding(.horizontal, 4) + } + .padding(6) + .background(Color.gray.opacity(0.1)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .frame(width: 80, height: 75) // Höher als Magazine + .shadow(color: .black.opacity(0.06), radius: 2, x: 0, y: 1) + } + + VStack(alignment: .leading, spacing: 4) { + Text(layout.displayName) + .font(.subheadline) + .fontWeight(.medium) + .foregroundColor(.primary) + + Text(layout.description) + .font(.caption) + .foregroundColor(.secondary) + .multilineTextAlignment(.leading) + } + + Spacer() + + if isSelected { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.blue) + .font(.title2) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 10) + .fill(isSelected ? Color.blue.opacity(0.1) : Color(.systemBackground)) + ) + .overlay( + RoundedRectangle(cornerRadius: 10) + .stroke(isSelected ? Color.blue : Color.clear, lineWidth: 2) + ) + } + .buttonStyle(.plain) + } +} + + +#Preview { + AppearanceSettingsView() + .cardStyle() + .padding() +} diff --git a/readeck/UI/Settings/CacheSettingsView.swift b/readeck/UI/Settings/CacheSettingsView.swift new file mode 100644 index 0000000..6130c83 --- /dev/null +++ b/readeck/UI/Settings/CacheSettingsView.swift @@ -0,0 +1,148 @@ +import SwiftUI +import Kingfisher + +struct CacheSettingsView: View { + @State private var cacheSize: String = "0 MB" + @State private var maxCacheSize: Double = 200 + @State private var isClearing: Bool = false + @State private var showClearAlert: Bool = false + + var body: some View { + VStack(spacing: 20) { + SectionHeader(title: "Cache Settings", icon: "internaldrive") + .padding(.bottom, 4) + + VStack(spacing: 12) { + HStack { + VStack(alignment: .leading, spacing: 2) { + Text("Current Cache Size") + .foregroundColor(.primary) + Text("\(cacheSize) / \(Int(maxCacheSize)) MB max") + .font(.caption) + .foregroundColor(.secondary) + } + Spacer() + Button("Refresh") { + updateCacheSize() + } + .font(.caption) + .foregroundColor(.blue) + } + + Divider() + + VStack(alignment: .leading, spacing: 8) { + HStack { + Text("Max Cache Size") + .foregroundColor(.primary) + Spacer() + Text("\(Int(maxCacheSize)) MB") + .font(.caption) + .foregroundColor(.secondary) + } + + Slider(value: $maxCacheSize, in: 50...1200, step: 50) { + Text("Max Cache Size") + } + .onChange(of: maxCacheSize) { _, newValue in + updateMaxCacheSize(newValue) + } + .accentColor(.blue) + } + + Divider() + + Button(action: { + showClearAlert = true + }) { + HStack { + if isClearing { + ProgressView() + .scaleEffect(0.8) + .frame(width: 24) + } else { + Image(systemName: "trash") + .foregroundColor(.red) + .frame(width: 24) + } + + VStack(alignment: .leading, spacing: 2) { + Text("Clear Cache") + .foregroundColor(isClearing ? .secondary : .red) + Text("Remove all cached images") + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + } + } + .disabled(isClearing) + } + } + .onAppear { + updateCacheSize() + loadMaxCacheSize() + } + .alert("Clear Cache", isPresented: $showClearAlert) { + Button("Cancel", role: .cancel) { } + Button("Clear", role: .destructive) { + clearCache() + } + } message: { + Text("This will remove all cached images. They will be downloaded again when needed.") + } + } + + private func updateCacheSize() { + KingfisherManager.shared.cache.calculateDiskStorageSize { result in + DispatchQueue.main.async { + switch result { + case .success(let size): + let mbSize = Double(size) / (1024 * 1024) + self.cacheSize = String(format: "%.1f MB", mbSize) + case .failure: + self.cacheSize = "Unknown" + } + } + } + } + + private func loadMaxCacheSize() { + let savedSize = UserDefaults.standard.object(forKey: "KingfisherMaxCacheSize") as? UInt + if let savedSize = savedSize { + maxCacheSize = Double(savedSize) / (1024 * 1024) + KingfisherManager.shared.cache.diskStorage.config.sizeLimit = savedSize + } else { + maxCacheSize = 200 + let defaultBytes = UInt(200 * 1024 * 1024) + KingfisherManager.shared.cache.diskStorage.config.sizeLimit = defaultBytes + UserDefaults.standard.set(defaultBytes, forKey: "KingfisherMaxCacheSize") + } + } + + private func updateMaxCacheSize(_ newSize: Double) { + let bytes = UInt(newSize * 1024 * 1024) + KingfisherManager.shared.cache.diskStorage.config.sizeLimit = bytes + UserDefaults.standard.set(bytes, forKey: "KingfisherMaxCacheSize") + } + + private func clearCache() { + isClearing = true + + KingfisherManager.shared.cache.clearDiskCache { + DispatchQueue.main.async { + self.isClearing = false + self.updateCacheSize() + } + } + + KingfisherManager.shared.cache.clearMemoryCache() + } +} + +#Preview { + CacheSettingsView() + .cardStyle() + .padding() +} \ No newline at end of file diff --git a/readeck/UI/Settings/FontSettingsView.swift b/readeck/UI/Settings/FontSettingsView.swift index 87efb88..b5c8810 100644 --- a/readeck/UI/Settings/FontSettingsView.swift +++ b/readeck/UI/Settings/FontSettingsView.swift @@ -15,17 +15,9 @@ struct FontSettingsView: View { } var body: some View { - VStack(alignment: .leading, spacing: 24) { - // Header - HStack(spacing: 8) { - Image(systemName: "textformat") - .font(.title2) - .foregroundColor(.accentColor) - - Text("Font") - .font(.title2) - .fontWeight(.bold) - } + VStack(spacing: 20) { + SectionHeader(title: "Font Settings", icon: "textformat") + .padding(.bottom, 4) // Font Family Picker HStack(alignment: .firstTextBaseline, spacing: 16) { diff --git a/readeck/UI/Settings/SettingsContainerView.swift b/readeck/UI/Settings/SettingsContainerView.swift index 469b84b..913d365 100644 --- a/readeck/UI/Settings/SettingsContainerView.swift +++ b/readeck/UI/Settings/SettingsContainerView.swift @@ -21,6 +21,12 @@ struct SettingsContainerView: View { FontSettingsView() .cardStyle() + AppearanceSettingsView() + .cardStyle() + + CacheSettingsView() + .cardStyle() + SettingsGeneralView() .cardStyle() @@ -103,9 +109,19 @@ struct SettingsContainerView: View { HStack(spacing: 8) { Image(systemName: "person.crop.circle") .foregroundColor(.secondary) - Text("Developer: Ilyas Hallak") + HStack(spacing: 4) { + Text("Developer:") + .font(.footnote) + .foregroundColor(.secondary) + Button("Ilyas Hallak") { + if let url = URL(string: "https://ilyashallak.de") { + UIApplication.shared.open(url) + } + } .font(.footnote) - .foregroundColor(.secondary) + .foregroundColor(.blue) + .underline() + } } HStack(spacing: 8) { Image(systemName: "globe") diff --git a/readeck/UI/Settings/SettingsGeneralView.swift b/readeck/UI/Settings/SettingsGeneralView.swift index 9145498..b1da02a 100644 --- a/readeck/UI/Settings/SettingsGeneralView.swift +++ b/readeck/UI/Settings/SettingsGeneralView.swift @@ -19,23 +19,6 @@ struct SettingsGeneralView: View { SectionHeader(title: "General Settings", icon: "gear") .padding(.bottom, 4) - // Theme - VStack(alignment: .leading, spacing: 12) { - Text("Theme") - .font(.headline) - Picker("Theme", selection: $viewModel.selectedTheme) { - ForEach(Theme.allCases, id: \.self) { theme in - Text(theme.displayName).tag(theme) - } - } - .pickerStyle(.segmented) - .onChange(of: viewModel.selectedTheme) { - Task { - await viewModel.saveGeneralSettings() - } - } - } - VStack(alignment: .leading, spacing: 12) { Text("General") .font(.headline) @@ -78,38 +61,6 @@ struct SettingsGeneralView: View { .toggleStyle(SwitchToggleStyle()) } - // Data Management - VStack(alignment: .leading, spacing: 12) { - Text("Data Management") - .font(.headline) - Button(role: .destructive) { - Task { - // await viewModel.clearCache() - } - } label: { - HStack { - Image(systemName: "trash") - .foregroundColor(.red) - Text("Clear cache") - .foregroundColor(.red) - Spacer() - } - } - Button(role: .destructive) { - Task { - // await viewModel.resetSettings() - } - } label: { - HStack { - Image(systemName: "arrow.clockwise") - .foregroundColor(.red) - Text("Reset settings") - .foregroundColor(.red) - Spacer() - } - } - } - // Messages if let successMessage = viewModel.successMessage { HStack { diff --git a/readeck/UI/Settings/SettingsGeneralViewModel.swift b/readeck/UI/Settings/SettingsGeneralViewModel.swift index 79f38b8..a6f92e9 100644 --- a/readeck/UI/Settings/SettingsGeneralViewModel.swift +++ b/readeck/UI/Settings/SettingsGeneralViewModel.swift @@ -52,7 +52,7 @@ class SettingsGeneralViewModel { successMessage = "Settings saved" // send notification to apply settings to the app - NotificationCenter.default.post(name: NSNotification.Name("SettingsChanged"), object: nil) + NotificationCenter.default.post(name: .settingsChanged, object: nil) } catch { errorMessage = "Error saving settings" } diff --git a/readeck/UI/Settings/SettingsServerView.swift b/readeck/UI/Settings/SettingsServerView.swift index c48b8fb..1250d8d 100644 --- a/readeck/UI/Settings/SettingsServerView.swift +++ b/readeck/UI/Settings/SettingsServerView.swift @@ -141,16 +141,18 @@ struct SettingsServerView: View { Button(action: { showingLogoutAlert = true }) { - HStack { + HStack(spacing: 6) { Image(systemName: "rectangle.portrait.and.arrow.right") + .font(.caption) Text("Logout") - .fontWeight(.semibold) + .font(.caption) + .fontWeight(.medium) } - .frame(maxWidth: .infinity) - .padding() - .background(Color.red) - .foregroundColor(.white) - .cornerRadius(10) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background(Color(.systemGray5)) + .foregroundColor(.secondary) + .cornerRadius(8) } } } diff --git a/readeck/UI/Settings/SettingsServerViewModel.swift b/readeck/UI/Settings/SettingsServerViewModel.swift index b801818..30abf33 100644 --- a/readeck/UI/Settings/SettingsServerViewModel.swift +++ b/readeck/UI/Settings/SettingsServerViewModel.swift @@ -67,7 +67,7 @@ class SettingsServerViewModel { isLoggedIn = true successMessage = "Server settings saved and successfully logged in." try await SettingsRepository().saveHasFinishedSetup(true) - NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil) + NotificationCenter.default.post(name: .setupStatusChanged, object: nil) } catch { errorMessage = "Connection or login failed: \(error.localizedDescription)" isLoggedIn = false @@ -80,7 +80,7 @@ class SettingsServerViewModel { try await logoutUseCase.execute() isLoggedIn = false successMessage = "Logged out" - NotificationCenter.default.post(name: NSNotification.Name("SetupStatusChanged"), object: nil) + NotificationCenter.default.post(name: .setupStatusChanged, object: nil) } catch { errorMessage = "Error logging out" } diff --git a/readeck/UI/Utils/NotificationNames.swift b/readeck/UI/Utils/NotificationNames.swift new file mode 100644 index 0000000..b1f692e --- /dev/null +++ b/readeck/UI/Utils/NotificationNames.swift @@ -0,0 +1,20 @@ +import Foundation + +extension Notification.Name { + // MARK: - App Lifecycle + static let settingsChanged = Notification.Name("SettingsChanged") + static let setupStatusChanged = Notification.Name("SetupStatusChanged") + + // MARK: - Authentication + static let unauthorizedAPIResponse = Notification.Name("UnauthorizedAPIResponse") + + // MARK: - Network + static let serverDidBecomeAvailable = Notification.Name("ServerDidBecomeAvailable") + + // MARK: - UI Interactions + static let dismissKeyboard = Notification.Name("DismissKeyboard") + static let addBookmarkFromShare = Notification.Name("AddBookmarkFromShare") + + // MARK: - User Preferences + static let cardLayoutChanged = Notification.Name("cardLayoutChanged") +} \ No newline at end of file diff --git a/readeck/UI/readeckApp.swift b/readeck/UI/readeckApp.swift index b87d659..c29c97d 100644 --- a/readeck/UI/readeckApp.swift +++ b/readeck/UI/readeckApp.swift @@ -35,7 +35,7 @@ struct readeckApp: App { await loadAppSettings() } } - .onReceive(NotificationCenter.default.publisher(for: NSNotification.Name("SettingsChanged"))) { _ in + .onReceive(NotificationCenter.default.publisher(for: .settingsChanged)) { _ in Task { await loadAppSettings() } diff --git a/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents b/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents index fcee5ec..cde8c61 100644 --- a/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents +++ b/readeck/readeck.xcdatamodeld/readeck.xcdatamodel/contents @@ -51,6 +51,7 @@ +