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:
parent
15ce5a223b
commit
8e8e67bfe1
@ -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" : {
|
||||
|
||||
|
||||
38
readeck/Assets.xcassets/splashBg.colorset/Contents.json
Normal file
38
readeck/Assets.xcassets/splashBg.colorset/Contents.json
Normal 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
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -25,7 +25,7 @@
|
||||
<key>UILaunchScreen</key>
|
||||
<dict>
|
||||
<key>UIColorName</key>
|
||||
<string>green2</string>
|
||||
<string>splashBg</string>
|
||||
<key>UIImageName</key>
|
||||
<string>readeck</string>
|
||||
</dict>
|
||||
|
||||
@ -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
|
||||
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 {
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import Foundation
|
||||
import Combine
|
||||
|
||||
@Observable
|
||||
class BookmarkDetailViewModel {
|
||||
@ -16,8 +17,11 @@ class BookmarkDetailViewModel {
|
||||
var isLoadingArticle = true
|
||||
var errorMessage: String?
|
||||
var settings: Settings?
|
||||
var readProgress: Int = 0
|
||||
|
||||
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) {
|
||||
self.getBookmarkUseCase = factory.makeGetBookmarkUseCase()
|
||||
@ -25,6 +29,16 @@ class BookmarkDetailViewModel {
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ struct BookmarkCardView: View {
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
ZStack(alignment: .bottomTrailing) {
|
||||
AsyncImage(url: imageURL) { image in
|
||||
image
|
||||
.resizable()
|
||||
@ -27,6 +28,32 @@ struct BookmarkCardView: View {
|
||||
}
|
||||
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text(bookmark.title)
|
||||
.font(.headline)
|
||||
@ -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
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user