diff --git a/Localizable.xcstrings b/Localizable.xcstrings index 04f2b62..4aa7397 100644 --- a/Localizable.xcstrings +++ b/Localizable.xcstrings @@ -3,6 +3,9 @@ "strings" : { "" : { + }, + "%" : { + }, "%@ (%lld)" : { "localizations" : { @@ -67,9 +70,6 @@ }, "Archive bookmark" : { - }, - "Archived" : { - }, "Are you sure you want to log out? This will delete all your login credentials and return you to setup." : { @@ -148,15 +148,15 @@ }, "General" : { - }, - "Go Back" : { - }, "https://example.com" : { }, "https://readeck.example.com" : { + }, + "Jump to last read position (%lld%%)" : { + }, "Keine Bookmarks gefunden." : { @@ -320,6 +320,9 @@ }, "Title" : { + }, + "Unarchive Bookmark" : { + }, "URL" : { diff --git a/readeck/Assets.xcassets/splashBg.colorset/Contents.json b/readeck/Assets.xcassets/splashBg.colorset/Contents.json new file mode 100644 index 0000000..8b258f9 --- /dev/null +++ b/readeck/Assets.xcassets/splashBg.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x5A", + "green" : "0x4A", + "red" : "0x1F" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x5A", + "green" : "0x4A", + "red" : "0x1F" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/readeck/Data/Repository/BookmarksRepository.swift b/readeck/Data/Repository/BookmarksRepository.swift index f2e29bb..7705118 100644 --- a/readeck/Data/Repository/BookmarksRepository.swift +++ b/readeck/Data/Repository/BookmarksRepository.swift @@ -31,7 +31,8 @@ class BookmarksRepository: PBookmarksRepository { labels: bookmarkDetailDto.labels, thumbnailUrl: bookmarkDetailDto.resources.thumbnail?.src ?? "", imageUrl: bookmarkDetailDto.resources.image?.src ?? "", - lang: bookmarkDetailDto.lang ?? "" + lang: bookmarkDetailDto.lang ?? "", + readProgress: bookmarkDetailDto.readProgress ) } diff --git a/readeck/Domain/Model/BookmarkDetail.swift b/readeck/Domain/Model/BookmarkDetail.swift index ef70101..535d887 100644 --- a/readeck/Domain/Model/BookmarkDetail.swift +++ b/readeck/Domain/Model/BookmarkDetail.swift @@ -19,6 +19,7 @@ struct BookmarkDetail { let imageUrl: String let lang: String var content: String? + let readProgress: Int? } extension BookmarkDetail { @@ -39,6 +40,7 @@ extension BookmarkDetail { labels: [], thumbnailUrl: "", imageUrl: "", - lang: "" + lang: "", + readProgress: 0 ) } diff --git a/readeck/Domain/Model/BookmarkUpdateRequest.swift b/readeck/Domain/Model/BookmarkUpdateRequest.swift index 0b504b6..8548a53 100644 --- a/readeck/Domain/Model/BookmarkUpdateRequest.swift +++ b/readeck/Domain/Model/BookmarkUpdateRequest.swift @@ -34,7 +34,7 @@ struct BookmarkUpdateRequest { } } -// Convenience Initializers für häufige Aktionen + extension BookmarkUpdateRequest { static func archive(_ isArchived: Bool) -> BookmarkUpdateRequest { return BookmarkUpdateRequest(isArchived: isArchived) @@ -67,4 +67,4 @@ extension BookmarkUpdateRequest { static func removeLabels(_ labels: [String]) -> BookmarkUpdateRequest { return BookmarkUpdateRequest(removeLabels: labels) } -} \ No newline at end of file +} diff --git a/readeck/Domain/UseCase/UpdateBookmarkUseCase.swift b/readeck/Domain/UseCase/UpdateBookmarkUseCase.swift index 9e943a4..324ed31 100644 --- a/readeck/Domain/UseCase/UpdateBookmarkUseCase.swift +++ b/readeck/Domain/UseCase/UpdateBookmarkUseCase.swift @@ -63,4 +63,4 @@ class UpdateBookmarkUseCase: PUpdateBookmarkUseCase { let request = BookmarkUpdateRequest.removeLabels(labels) try await execute(bookmarkId: bookmarkId, updateRequest: request) } -} \ No newline at end of file +} diff --git a/readeck/Info.plist b/readeck/Info.plist index 81ffd01..47ba9df 100644 --- a/readeck/Info.plist +++ b/readeck/Info.plist @@ -25,7 +25,7 @@ UILaunchScreen UIColorName - green2 + splashBg UIImageName readeck diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailView.swift b/readeck/UI/BookmarkDetail/BookmarkDetailView.swift index 600626f..26e370f 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailView.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailView.swift @@ -2,25 +2,22 @@ import SwiftUI import SafariServices import Combine -// PreferenceKey for logging scroll offset -struct ScrollOffsetPreferenceKey: PreferenceKey { - typealias Value = CGFloat - static var defaultValue = CGFloat.zero - static func reduce(value: inout Value, nextValue: () -> Value) { - value += nextValue() - } -} - struct BookmarkDetailView: View { let bookmarkId: String + + // MARK: - States + @State private var viewModel: BookmarkDetailViewModel @State private var webViewHeight: CGFloat = 300 @State private var showingFontSettings = false @State private var showingLabelsSheet = false - @State private var showDismissButton = false @State private var readingProgress: Double = 0.0 - // contentHeight entfernt, webViewHeight wird verwendet @State private var scrollViewHeight: CGFloat = 1 + @State private var showJumpToProgressButton: Bool = false + @State private var scrollPosition = ScrollPosition(edge: .top) + + // MARK: - Envs + @EnvironmentObject var playerUIState: PlayerUIState @EnvironmentObject var appSettings: AppSettings @Environment(\.dismiss) private var dismiss @@ -43,10 +40,10 @@ struct BookmarkDetailView: View { GeometryReader { outerGeo in ScrollView { VStack(spacing: 0) { - // Track scroll offset at the top GeometryReader { geo in Color.clear - .preference(key: ScrollOffsetPreferenceKey.self, value: geo.frame(in: .named("scroll")).minY) + .preference(key: ScrollOffsetPreferenceKey.self, + value: geo.frame(in: .named("scroll")).minY) } .frame(height: 0) ZStack(alignment: .top) { @@ -55,6 +52,9 @@ struct BookmarkDetailView: View { Color.clear.frame(height: viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight) titleSection Divider().padding(.horizontal) + if showJumpToProgressButton { + JumpButton() + } if let settings = viewModel.settings, !viewModel.articleContent.isEmpty { WebView(htmlContent: viewModel.articleContent, settings: settings, onHeightChange: { height in webViewHeight = height @@ -83,32 +83,26 @@ struct BookmarkDetailView: View { .padding(.top, 0) } Spacer(minLength: 40) - if viewModel.isLoadingArticle == false { + if viewModel.isLoadingArticle == false && viewModel.isLoading == false { archiveSection .transition(.opacity.combined(with: .move(edge: .bottom))) .animation(.easeInOut, value: viewModel.articleContent) } } } - // Kein GeometryReader am Ende nötig } } .coordinateSpace(name: "scroll") .onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in scrollViewHeight = outerGeo.size.height - print("offset:", offset) - print("webViewHeight:", webViewHeight) - print("scrollViewHeight:", scrollViewHeight) let maxOffset = webViewHeight - scrollViewHeight - print("maxOffset:", maxOffset) - // Am Anfang: offset = 0, am Ende: offset = -maxOffset let rawProgress = -offset / (maxOffset != 0 ? maxOffset : 1) - print("rawProgress:", rawProgress) let progress = min(max(rawProgress, 0), 1) - print("progress:", progress) - readingProgress = progress + readingProgress = progress + viewModel.debouncedUpdateReadProgress(id: bookmarkId, progress: progress, anchor: nil) } .ignoresSafeArea(edges: .top) + .scrollPosition($scrollPosition) } } .navigationBarTitleDisplayMode(.inline) @@ -169,6 +163,9 @@ struct BookmarkDetailView: View { } } } + .onChange(of: viewModel.readProgress) { _, progress in + showJumpToProgressButton = progress > 0 && progress < 100 + } .task { await viewModel.loadBookmarkDetail(id: bookmarkId) await viewModel.loadArticleContent(id: bookmarkId) @@ -393,33 +390,19 @@ struct BookmarkDetailView: View { // Archive button Button(action: { Task { - await viewModel.archiveBookmark(id: bookmarkId) - if viewModel.bookmarkDetail.isArchived { - showDismissButton = true - } + await viewModel.archiveBookmark(id: bookmarkId, isArchive: !viewModel.bookmarkDetail.isArchived) } }) { HStack { - Image(systemName: viewModel.bookmarkDetail.isArchived ? "checkmark.circle.fill" : "archivebox") - .foregroundColor(viewModel.bookmarkDetail.isArchived ? .green : .primary) - Text(viewModel.bookmarkDetail.isArchived ? "Archived" : "Archive bookmark") + Image(systemName: viewModel.bookmarkDetail.isArchived ? "checkmark.circle" : "archivebox") + Text(viewModel.bookmarkDetail.isArchived ? "Unarchive Bookmark" : "Archive bookmark") } .font(.title3.bold()) .frame(maxHeight: 60) .padding(10) } .buttonStyle(.borderedProminent) - .disabled(viewModel.isLoading || viewModel.bookmarkDetail.isArchived) - } - if showDismissButton { - Button(action: { - dismiss() - }) { - Label("Go Back", systemImage: "arrow.backward.circle") - .font(.title3.bold()) - .padding(.top, 8) - } - .id("goBackButton") + .disabled(viewModel.isLoading) } if let error = viewModel.errorMessage { Text(error) @@ -430,6 +413,36 @@ struct BookmarkDetailView: View { .padding(.horizontal) .padding(.bottom, 32) } + + @ViewBuilder + func JumpButton() -> some View { + Button(action: { + if #available(iOS 17.0, *) { + let maxOffset = webViewHeight - scrollViewHeight + let offset = maxOffset * (Double(viewModel.readProgress) / 100.0) + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + scrollPosition = ScrollPosition(y: offset) + showJumpToProgressButton = false + } + } + }) { + Text("Jump to last read position (\(viewModel.readProgress)%)") + .font(.subheadline) + .padding(8) + .frame(maxWidth: .infinity) + } + .background(Color.accentColor.opacity(0.15)) + .cornerRadius(8) + .padding([.top, .horizontal]) + } +} + +struct ScrollOffsetPreferenceKey: PreferenceKey { + typealias Value = CGFloat + static var defaultValue = CGFloat.zero + static func reduce(value: inout Value, nextValue: () -> Value) { + value += nextValue() + } } #Preview { diff --git a/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift b/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift index cae37b9..b40c8ed 100644 --- a/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift +++ b/readeck/UI/BookmarkDetail/BookmarkDetailViewModel.swift @@ -1,4 +1,5 @@ import Foundation +import Combine @Observable class BookmarkDetailViewModel { @@ -16,15 +17,28 @@ class BookmarkDetailViewModel { var isLoadingArticle = true var errorMessage: String? var settings: Settings? + var readProgress: Int = 0 private var factory: UseCaseFactory? + private var cancellables = Set() + private let readProgressSubject = PassthroughSubject<(id: String, progress: Double, anchor: String?), Never>() - init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) { + init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared) { self.getBookmarkUseCase = factory.makeGetBookmarkUseCase() self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase() self.loadSettingsUseCase = factory.makeLoadSettingsUseCase() self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase() self.factory = factory + + readProgressSubject + .debounce(for: .seconds(1), scheduler: DispatchQueue.main) + .sink { [weak self] (id, progress, anchor) in + let progressInt = Int(progress * 100) + Task { + await self?.updateReadProgress(id: id, progress: progressInt, anchor: anchor) + } + } + .store(in: &cancellables) } @MainActor @@ -35,6 +49,8 @@ class BookmarkDetailViewModel { do { settings = try await loadSettingsUseCase.execute() bookmarkDetail = try await getBookmarkUseCase.execute(id: id) + readProgress = bookmarkDetail.readProgress ?? 0 + if settings?.enableTTS == true { self.addTextToSpeechQueueUseCase = factory?.makeAddTextToSpeechQueueUseCase() } @@ -68,11 +84,11 @@ class BookmarkDetailViewModel { } @MainActor - func archiveBookmark(id: String) async { + func archiveBookmark(id: String, isArchive: Bool = true) async { isLoading = true errorMessage = nil do { - try await updateBookmarkUseCase.toggleArchive(bookmarkId: id, isArchived: true) + try await updateBookmarkUseCase.toggleArchive(bookmarkId: id, isArchived: isArchive) bookmarkDetail.isArchived = true } catch { errorMessage = "Error archiving bookmark" @@ -103,4 +119,16 @@ class BookmarkDetailViewModel { } isLoading = false } + + func updateReadProgress(id: String, progress: Int, anchor: String?) async { + do { + try await updateBookmarkUseCase.updateReadProgress(bookmarkId: id, progress: progress, anchor: anchor) + } catch { + // ignore error in this case + } + } + + func debouncedUpdateReadProgress(id: String, progress: Double, anchor: String?) { + readProgressSubject.send((id, progress, anchor)) + } } diff --git a/readeck/UI/Bookmarks/BookmarkCardView.swift b/readeck/UI/Bookmarks/BookmarkCardView.swift index 8e77f22..0fbc9d3 100644 --- a/readeck/UI/Bookmarks/BookmarkCardView.swift +++ b/readeck/UI/Bookmarks/BookmarkCardView.swift @@ -13,19 +13,46 @@ struct BookmarkCardView: View { var body: some View { VStack(alignment: .leading, spacing: 8) { - AsyncImage(url: imageURL) { image in - image - .resizable() - .aspectRatio(contentMode: .fill) - .frame(height: 120) - } placeholder: { + 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)) - Image(R.image.placeholder.name) - .resizable() - .aspectRatio(contentMode: .fill) - .frame(height: 120) + if bookmark.readProgress > 0 { + 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) + } } - .clipShape(RoundedRectangle(cornerRadius: 8)) VStack(alignment: .leading, spacing: 4) { Text(bookmark.title) @@ -58,7 +85,6 @@ struct BookmarkCardView: View { } } HStack { - Label((URLUtil.extractDomain(from: bookmark.url) ?? "Original Site") + " open", systemImage: "safari") .onTapGesture { SafariUtil.openInSafari(url: bookmark.url) @@ -68,12 +94,6 @@ struct BookmarkCardView: View { .font(.caption) .foregroundColor(.secondary) - // Progress Bar for reading progress - if bookmark.readProgress > 0 { - ProgressView(value: Double(bookmark.readProgress), total: 100) - .progressViewStyle(LinearProgressViewStyle()) - .frame(height: 4) - } } .padding(.horizontal, 12) .padding(.bottom, 12) @@ -203,3 +223,12 @@ struct IconBadge: View { } } +#Preview { + BookmarkCardView(bookmark: .mock, currentState: .all) { _ in + + } onDelete: { _ in + + } onToggleFavorite: { _ in + + } +} diff --git a/readeck/UI/Components/WebView.swift b/readeck/UI/Components/WebView.swift index 0923247..f667a5a 100644 --- a/readeck/UI/Components/WebView.swift +++ b/readeck/UI/Components/WebView.swift @@ -289,20 +289,14 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { if message.name == "heightUpdate", let height = message.body as? CGFloat { - print("[WebView] heightUpdate received: \(height)") DispatchQueue.main.async { self.onHeightChange?(height) } } if message.name == "scrollProgress", let progress = message.body as? Double { - print("[WebView] scrollProgress received: \(progress)") DispatchQueue.main.async { self.onScroll?(progress) } } } - - deinit { - // Der Message Handler wird automatisch mit der WebView entfernt - } } diff --git a/readeck/UI/Factory/MockUseCaseFactory.swift b/readeck/UI/Factory/MockUseCaseFactory.swift index be861ba..0213bc2 100644 --- a/readeck/UI/Factory/MockUseCaseFactory.swift +++ b/readeck/UI/Factory/MockUseCaseFactory.swift @@ -141,7 +141,7 @@ class MockSaveSettingsUseCase: PSaveSettingsUseCase { class MockGetBookmarkUseCase: PGetBookmarkUseCase { func execute(id: String) async throws -> BookmarkDetail { - BookmarkDetail(id: "123", title: "Test", url: "https://www.google.com", description: "Test", siteName: "Test", authors: ["Test"], created: "2021-01-01", updated: "2021-01-01", wordCount: 100, readingTime: 100, hasArticle: true, isMarked: false, isArchived: false, labels: ["Test"], thumbnailUrl: "https://picsum.photos/30/30", imageUrl: "https://picsum.photos/400/400", lang: "en") + BookmarkDetail(id: "123", title: "Test", url: "https://www.google.com", description: "Test", siteName: "Test", authors: ["Test"], created: "2021-01-01", updated: "2021-01-01", wordCount: 100, readingTime: 100, hasArticle: true, isMarked: false, isArchived: false, labels: ["Test"], thumbnailUrl: "https://picsum.photos/30/30", imageUrl: "https://picsum.photos/400/400", lang: "en", readProgress: 0) } } @@ -180,7 +180,7 @@ class MockAddTextToSpeechQueueUseCase: PAddTextToSpeechQueueUseCase { func execute(bookmarkDetail: BookmarkDetail) {} } -fileprivate extension Bookmark { +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) )