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