UI/UX: Bookmark Detail and CardView improvements

- Progress indicator as a compact circle at the bottom right of the CardView, with percent display
- Jump-to-progress button in detail view, using ScrollPosition logic (removed iOS 17 mention)
- Archive/Unarchive button with flexible parameter and label
- Various bugfixes and refactoring (progress, mock, WebView, strings)
- Improved reading progress logic and display
- Code cleanup: removed debug prints, mutated properties directly
This commit is contained in:
Ilyas Hallak 2025-07-23 22:15:21 +02:00
parent 15ce5a223b
commit 8e8e67bfe1
12 changed files with 190 additions and 82 deletions

View File

@ -3,6 +3,9 @@
"strings" : { "strings" : {
"" : { "" : {
},
"%" : {
}, },
"%@ (%lld)" : { "%@ (%lld)" : {
"localizations" : { "localizations" : {
@ -67,9 +70,6 @@
}, },
"Archive bookmark" : { "Archive bookmark" : {
},
"Archived" : {
}, },
"Are you sure you want to log out? This will delete all your login credentials and return you to setup." : { "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" : { "General" : {
},
"Go Back" : {
}, },
"https://example.com" : { "https://example.com" : {
}, },
"https://readeck.example.com" : { "https://readeck.example.com" : {
},
"Jump to last read position (%lld%%)" : {
}, },
"Keine Bookmarks gefunden." : { "Keine Bookmarks gefunden." : {
@ -320,6 +320,9 @@
}, },
"Title" : { "Title" : {
},
"Unarchive Bookmark" : {
}, },
"URL" : { "URL" : {

View File

@ -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
}
}

View File

@ -31,7 +31,8 @@ class BookmarksRepository: PBookmarksRepository {
labels: bookmarkDetailDto.labels, labels: bookmarkDetailDto.labels,
thumbnailUrl: bookmarkDetailDto.resources.thumbnail?.src ?? "", thumbnailUrl: bookmarkDetailDto.resources.thumbnail?.src ?? "",
imageUrl: bookmarkDetailDto.resources.image?.src ?? "", imageUrl: bookmarkDetailDto.resources.image?.src ?? "",
lang: bookmarkDetailDto.lang ?? "" lang: bookmarkDetailDto.lang ?? "",
readProgress: bookmarkDetailDto.readProgress
) )
} }

View File

@ -19,6 +19,7 @@ struct BookmarkDetail {
let imageUrl: String let imageUrl: String
let lang: String let lang: String
var content: String? var content: String?
let readProgress: Int?
} }
extension BookmarkDetail { extension BookmarkDetail {
@ -39,6 +40,7 @@ extension BookmarkDetail {
labels: [], labels: [],
thumbnailUrl: "", thumbnailUrl: "",
imageUrl: "", imageUrl: "",
lang: "" lang: "",
readProgress: 0
) )
} }

View File

@ -34,7 +34,7 @@ struct BookmarkUpdateRequest {
} }
} }
// Convenience Initializers für häufige Aktionen
extension BookmarkUpdateRequest { extension BookmarkUpdateRequest {
static func archive(_ isArchived: Bool) -> BookmarkUpdateRequest { static func archive(_ isArchived: Bool) -> BookmarkUpdateRequest {
return BookmarkUpdateRequest(isArchived: isArchived) return BookmarkUpdateRequest(isArchived: isArchived)
@ -67,4 +67,4 @@ extension BookmarkUpdateRequest {
static func removeLabels(_ labels: [String]) -> BookmarkUpdateRequest { static func removeLabels(_ labels: [String]) -> BookmarkUpdateRequest {
return BookmarkUpdateRequest(removeLabels: labels) return BookmarkUpdateRequest(removeLabels: labels)
} }
} }

View File

@ -63,4 +63,4 @@ class UpdateBookmarkUseCase: PUpdateBookmarkUseCase {
let request = BookmarkUpdateRequest.removeLabels(labels) let request = BookmarkUpdateRequest.removeLabels(labels)
try await execute(bookmarkId: bookmarkId, updateRequest: request) try await execute(bookmarkId: bookmarkId, updateRequest: request)
} }
} }

View File

@ -25,7 +25,7 @@
<key>UILaunchScreen</key> <key>UILaunchScreen</key>
<dict> <dict>
<key>UIColorName</key> <key>UIColorName</key>
<string>green2</string> <string>splashBg</string>
<key>UIImageName</key> <key>UIImageName</key>
<string>readeck</string> <string>readeck</string>
</dict> </dict>

View File

@ -2,25 +2,22 @@ import SwiftUI
import SafariServices import SafariServices
import Combine 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 { struct BookmarkDetailView: View {
let bookmarkId: String let bookmarkId: String
// MARK: - States
@State private var viewModel: BookmarkDetailViewModel @State private var viewModel: BookmarkDetailViewModel
@State private var webViewHeight: CGFloat = 300 @State private var webViewHeight: CGFloat = 300
@State private var showingFontSettings = false @State private var showingFontSettings = false
@State private var showingLabelsSheet = false @State private var showingLabelsSheet = false
@State private var showDismissButton = false
@State private var readingProgress: Double = 0.0 @State private var readingProgress: Double = 0.0
// contentHeight entfernt, webViewHeight wird verwendet
@State private var scrollViewHeight: CGFloat = 1 @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 playerUIState: PlayerUIState
@EnvironmentObject var appSettings: AppSettings @EnvironmentObject var appSettings: AppSettings
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@ -43,10 +40,10 @@ struct BookmarkDetailView: View {
GeometryReader { outerGeo in GeometryReader { outerGeo in
ScrollView { ScrollView {
VStack(spacing: 0) { VStack(spacing: 0) {
// Track scroll offset at the top
GeometryReader { geo in GeometryReader { geo in
Color.clear 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) .frame(height: 0)
ZStack(alignment: .top) { ZStack(alignment: .top) {
@ -55,6 +52,9 @@ struct BookmarkDetailView: View {
Color.clear.frame(height: viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight) Color.clear.frame(height: viewModel.bookmarkDetail.imageUrl.isEmpty ? 84 : headerHeight)
titleSection titleSection
Divider().padding(.horizontal) Divider().padding(.horizontal)
if showJumpToProgressButton {
JumpButton()
}
if let settings = viewModel.settings, !viewModel.articleContent.isEmpty { if let settings = viewModel.settings, !viewModel.articleContent.isEmpty {
WebView(htmlContent: viewModel.articleContent, settings: settings, onHeightChange: { height in WebView(htmlContent: viewModel.articleContent, settings: settings, onHeightChange: { height in
webViewHeight = height webViewHeight = height
@ -83,32 +83,26 @@ struct BookmarkDetailView: View {
.padding(.top, 0) .padding(.top, 0)
} }
Spacer(minLength: 40) Spacer(minLength: 40)
if viewModel.isLoadingArticle == false { if viewModel.isLoadingArticle == false && viewModel.isLoading == false {
archiveSection archiveSection
.transition(.opacity.combined(with: .move(edge: .bottom))) .transition(.opacity.combined(with: .move(edge: .bottom)))
.animation(.easeInOut, value: viewModel.articleContent) .animation(.easeInOut, value: viewModel.articleContent)
} }
} }
} }
// Kein GeometryReader am Ende nötig
} }
} }
.coordinateSpace(name: "scroll") .coordinateSpace(name: "scroll")
.onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in .onPreferenceChange(ScrollOffsetPreferenceKey.self) { offset in
scrollViewHeight = outerGeo.size.height scrollViewHeight = outerGeo.size.height
print("offset:", offset)
print("webViewHeight:", webViewHeight)
print("scrollViewHeight:", scrollViewHeight)
let maxOffset = webViewHeight - scrollViewHeight let maxOffset = webViewHeight - scrollViewHeight
print("maxOffset:", maxOffset)
// Am Anfang: offset = 0, am Ende: offset = -maxOffset
let rawProgress = -offset / (maxOffset != 0 ? maxOffset : 1) let rawProgress = -offset / (maxOffset != 0 ? maxOffset : 1)
print("rawProgress:", rawProgress)
let progress = min(max(rawProgress, 0), 1) 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) .ignoresSafeArea(edges: .top)
.scrollPosition($scrollPosition)
} }
} }
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
@ -169,6 +163,9 @@ struct BookmarkDetailView: View {
} }
} }
} }
.onChange(of: viewModel.readProgress) { _, progress in
showJumpToProgressButton = progress > 0 && progress < 100
}
.task { .task {
await viewModel.loadBookmarkDetail(id: bookmarkId) await viewModel.loadBookmarkDetail(id: bookmarkId)
await viewModel.loadArticleContent(id: bookmarkId) await viewModel.loadArticleContent(id: bookmarkId)
@ -393,33 +390,19 @@ struct BookmarkDetailView: View {
// Archive button // Archive button
Button(action: { Button(action: {
Task { Task {
await viewModel.archiveBookmark(id: bookmarkId) await viewModel.archiveBookmark(id: bookmarkId, isArchive: !viewModel.bookmarkDetail.isArchived)
if viewModel.bookmarkDetail.isArchived {
showDismissButton = true
}
} }
}) { }) {
HStack { HStack {
Image(systemName: viewModel.bookmarkDetail.isArchived ? "checkmark.circle.fill" : "archivebox") Image(systemName: viewModel.bookmarkDetail.isArchived ? "checkmark.circle" : "archivebox")
.foregroundColor(viewModel.bookmarkDetail.isArchived ? .green : .primary) Text(viewModel.bookmarkDetail.isArchived ? "Unarchive Bookmark" : "Archive bookmark")
Text(viewModel.bookmarkDetail.isArchived ? "Archived" : "Archive bookmark")
} }
.font(.title3.bold()) .font(.title3.bold())
.frame(maxHeight: 60) .frame(maxHeight: 60)
.padding(10) .padding(10)
} }
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
.disabled(viewModel.isLoading || viewModel.bookmarkDetail.isArchived) .disabled(viewModel.isLoading)
}
if showDismissButton {
Button(action: {
dismiss()
}) {
Label("Go Back", systemImage: "arrow.backward.circle")
.font(.title3.bold())
.padding(.top, 8)
}
.id("goBackButton")
} }
if let error = viewModel.errorMessage { if let error = viewModel.errorMessage {
Text(error) Text(error)
@ -430,6 +413,36 @@ struct BookmarkDetailView: View {
.padding(.horizontal) .padding(.horizontal)
.padding(.bottom, 32) .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 { #Preview {

View File

@ -1,4 +1,5 @@
import Foundation import Foundation
import Combine
@Observable @Observable
class BookmarkDetailViewModel { class BookmarkDetailViewModel {
@ -16,15 +17,28 @@ class BookmarkDetailViewModel {
var isLoadingArticle = true var isLoadingArticle = true
var errorMessage: String? var errorMessage: String?
var settings: Settings? var settings: Settings?
var readProgress: Int = 0
private var factory: UseCaseFactory? private var factory: UseCaseFactory?
private var cancellables = Set<AnyCancellable>()
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.getBookmarkUseCase = factory.makeGetBookmarkUseCase()
self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase() self.getBookmarkArticleUseCase = factory.makeGetBookmarkArticleUseCase()
self.loadSettingsUseCase = factory.makeLoadSettingsUseCase() self.loadSettingsUseCase = factory.makeLoadSettingsUseCase()
self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase() self.updateBookmarkUseCase = factory.makeUpdateBookmarkUseCase()
self.factory = factory 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 @MainActor
@ -35,6 +49,8 @@ class BookmarkDetailViewModel {
do { do {
settings = try await loadSettingsUseCase.execute() settings = try await loadSettingsUseCase.execute()
bookmarkDetail = try await getBookmarkUseCase.execute(id: id) bookmarkDetail = try await getBookmarkUseCase.execute(id: id)
readProgress = bookmarkDetail.readProgress ?? 0
if settings?.enableTTS == true { if settings?.enableTTS == true {
self.addTextToSpeechQueueUseCase = factory?.makeAddTextToSpeechQueueUseCase() self.addTextToSpeechQueueUseCase = factory?.makeAddTextToSpeechQueueUseCase()
} }
@ -68,11 +84,11 @@ class BookmarkDetailViewModel {
} }
@MainActor @MainActor
func archiveBookmark(id: String) async { func archiveBookmark(id: String, isArchive: Bool = true) async {
isLoading = true isLoading = true
errorMessage = nil errorMessage = nil
do { do {
try await updateBookmarkUseCase.toggleArchive(bookmarkId: id, isArchived: true) try await updateBookmarkUseCase.toggleArchive(bookmarkId: id, isArchived: isArchive)
bookmarkDetail.isArchived = true bookmarkDetail.isArchived = true
} catch { } catch {
errorMessage = "Error archiving bookmark" errorMessage = "Error archiving bookmark"
@ -103,4 +119,16 @@ class BookmarkDetailViewModel {
} }
isLoading = false 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))
}
} }

View File

@ -13,19 +13,46 @@ struct BookmarkCardView: View {
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
AsyncImage(url: imageURL) { image in ZStack(alignment: .bottomTrailing) {
image AsyncImage(url: imageURL) { image in
.resizable() image
.aspectRatio(contentMode: .fill) .resizable()
.frame(height: 120) .aspectRatio(contentMode: .fill)
} placeholder: { .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) if bookmark.readProgress > 0 {
.resizable() ZStack {
.aspectRatio(contentMode: .fill) Circle()
.frame(height: 120) .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) { VStack(alignment: .leading, spacing: 4) {
Text(bookmark.title) Text(bookmark.title)
@ -58,7 +85,6 @@ struct BookmarkCardView: View {
} }
} }
HStack { HStack {
Label((URLUtil.extractDomain(from: bookmark.url) ?? "Original Site") + " open", systemImage: "safari") Label((URLUtil.extractDomain(from: bookmark.url) ?? "Original Site") + " open", systemImage: "safari")
.onTapGesture { .onTapGesture {
SafariUtil.openInSafari(url: bookmark.url) SafariUtil.openInSafari(url: bookmark.url)
@ -68,12 +94,6 @@ struct BookmarkCardView: View {
.font(.caption) .font(.caption)
.foregroundColor(.secondary) .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(.horizontal, 12)
.padding(.bottom, 12) .padding(.bottom, 12)
@ -203,3 +223,12 @@ struct IconBadge: View {
} }
} }
#Preview {
BookmarkCardView(bookmark: .mock, currentState: .all) { _ in
} onDelete: { _ in
} onToggleFavorite: { _ in
}
}

View File

@ -289,20 +289,14 @@ class WebViewCoordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
if message.name == "heightUpdate", let height = message.body as? CGFloat { if message.name == "heightUpdate", let height = message.body as? CGFloat {
print("[WebView] heightUpdate received: \(height)")
DispatchQueue.main.async { DispatchQueue.main.async {
self.onHeightChange?(height) self.onHeightChange?(height)
} }
} }
if message.name == "scrollProgress", let progress = message.body as? Double { if message.name == "scrollProgress", let progress = message.body as? Double {
print("[WebView] scrollProgress received: \(progress)")
DispatchQueue.main.async { DispatchQueue.main.async {
self.onScroll?(progress) self.onScroll?(progress)
} }
} }
} }
deinit {
// Der Message Handler wird automatisch mit der WebView entfernt
}
} }

View File

@ -141,7 +141,7 @@ class MockSaveSettingsUseCase: PSaveSettingsUseCase {
class MockGetBookmarkUseCase: PGetBookmarkUseCase { class MockGetBookmarkUseCase: PGetBookmarkUseCase {
func execute(id: String) async throws -> BookmarkDetail { 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) {} func execute(bookmarkDetail: BookmarkDetail) {}
} }
fileprivate extension Bookmark { extension Bookmark {
static let mock: Bookmark = .init( 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) 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)
) )