feat: optimize label pagination and read progress handling

- Optimize calculatePages() to show single page when ≤12 labels
- Add loading animation for initial label loading only
- Unify label filtering logic in ViewModel instead of UI
- Fix read progress regression by always taking higher value
- Prevent server updates with lower progress values
- Improve UX with better loading states and pagination
This commit is contained in:
Ilyas Hallak 2025-07-30 16:09:40 +02:00
parent 1cb87a4fb7
commit 03713230b0
6 changed files with 109 additions and 32 deletions

View File

@ -101,7 +101,7 @@
"Close" : { "Close" : {
}, },
"Current labels" : { "Current tags" : {
}, },
"Data Management" : { "Data Management" : {
@ -187,6 +187,9 @@
}, },
"Loading article..." : { "Loading article..." : {
},
"Loading tags..." : {
}, },
"Login & Save" : { "Login & Save" : {
@ -214,6 +217,9 @@
}, },
"No bookmarks found in %@." : { "No bookmarks found in %@." : {
},
"No tags available" : {
}, },
"OK" : { "OK" : {

View File

@ -620,7 +620,7 @@
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements; CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10; CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
DEVELOPMENT_TEAM = 8J69P655GN; DEVELOPMENT_TEAM = 8J69P655GN;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;
@ -664,7 +664,7 @@
CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements; CODE_SIGN_ENTITLEMENTS = readeck/readeck.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10; CURRENT_PROJECT_VERSION = 11;
DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\""; DEVELOPMENT_ASSET_PATHS = "\"readeck/Preview Content\"";
DEVELOPMENT_TEAM = 8J69P655GN; DEVELOPMENT_TEAM = 8J69P655GN;
ENABLE_HARDENED_RUNTIME = YES; ENABLE_HARDENED_RUNTIME = YES;

View File

@ -5,9 +5,9 @@
"color-space" : "srgb", "color-space" : "srgb",
"components" : { "components" : {
"alpha" : "1.000", "alpha" : "1.000",
"blue" : "0x60", "blue" : "0x5A",
"green" : "0x4E", "green" : "0x4A",
"red" : "0x01" "red" : "0x1F"
} }
}, },
"idiom" : "universal" "idiom" : "universal"

View File

@ -49,7 +49,10 @@ 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
// Always take the higher value between server and local progress
let serverProgress = bookmarkDetail.readProgress ?? 0
readProgress = max(readProgress, serverProgress)
if settings?.enableTTS == true { if settings?.enableTTS == true {
self.addTextToSpeechQueueUseCase = factory?.makeAddTextToSpeechQueueUseCase() self.addTextToSpeechQueueUseCase = factory?.makeAddTextToSpeechQueueUseCase()
@ -121,12 +124,15 @@ class BookmarkDetailViewModel {
} }
func updateReadProgress(id: String, progress: Int, anchor: String?) async { func updateReadProgress(id: String, progress: Int, anchor: String?) async {
// Only update if the new progress is higher than current
if progress > readProgress {
do { do {
try await updateBookmarkUseCase.updateReadProgress(bookmarkId: id, progress: progress, anchor: anchor) try await updateBookmarkUseCase.updateReadProgress(bookmarkId: id, progress: progress, anchor: anchor)
} catch { } catch {
// ignore error in this case // ignore error in this case
} }
} }
}
func debouncedUpdateReadProgress(id: String, progress: Double, anchor: String?) { func debouncedUpdateReadProgress(id: String, progress: Double, anchor: String?) {
readProgressSubject.send((id, progress, anchor)) readProgressSubject.send((id, progress, anchor))

View File

@ -42,21 +42,48 @@ struct BookmarkLabelsView: View {
.padding(.horizontal) .padding(.horizontal)
// All available labels // All available labels
if !viewModel.allLabels.isEmpty {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("All available tags") Text("All available tags")
.font(.headline) .font(.headline)
.padding(.horizontal) .padding(.horizontal)
if viewModel.isInitialLoading {
// Loading state
VStack {
ProgressView()
.scaleEffect(1.2)
.padding(.vertical, 40)
Text("Loading tags...")
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.frame(height: 180)
} else if viewModel.allLabels.isEmpty {
// Empty state
VStack {
Image(systemName: "tag")
.font(.system(size: 40))
.foregroundColor(.secondary)
.padding(.vertical, 20)
Text("No tags available")
.font(.caption)
.foregroundColor(.secondary)
}
.frame(maxWidth: .infinity)
.frame(height: 180)
} else {
// Content state
TabView { TabView {
ForEach(Array(viewModel.labelPages.enumerated()), id: \ .offset) { pageIndex, labelsPage in ForEach(Array(viewModel.availableLabelPages.enumerated()), id: \.offset) { pageIndex, labelsPage in
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) { LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible()), GridItem(.flexible())], alignment: .leading, spacing: 8) {
ForEach(labelsPage.filter { !viewModel.currentLabels.contains($0.name) }, id: \ .id) { label in ForEach(labelsPage, id: \.id) { label in
UnifiedLabelChip( UnifiedLabelChip(
label: label.name, label: label.name,
isSelected: viewModel.currentLabels.contains(label.name), isSelected: viewModel.currentLabels.contains(label.name),
isRemovable: false, isRemovable: false,
onTap: { onTap: {
print("addLabelsUseCase")
Task { Task {
await viewModel.toggleLabel(for: bookmarkId, label: label.name) await viewModel.toggleLabel(for: bookmarkId, label: label.name)
} }
@ -68,7 +95,7 @@ struct BookmarkLabelsView: View {
.padding(.horizontal) .padding(.horizontal)
} }
} }
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .automatic)) .tabViewStyle(.page(indexDisplayMode: viewModel.availableLabelPages.count > 1 ? .automatic : .never))
.frame(height: 180) .frame(height: 180)
.padding(.top, -20) .padding(.top, -20)
} }
@ -77,7 +104,7 @@ struct BookmarkLabelsView: View {
// Current labels // Current labels
if !viewModel.currentLabels.isEmpty { if !viewModel.currentLabels.isEmpty {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
Text("Current labels") Text("Current tags")
.font(.headline) .font(.headline)
.padding(.horizontal) .padding(.horizontal)

View File

@ -7,23 +7,31 @@ class BookmarkLabelsViewModel {
private let getLabelsUseCase: PGetLabelsUseCase private let getLabelsUseCase: PGetLabelsUseCase
var isLoading = false var isLoading = false
var isInitialLoading = false
var errorMessage: String? var errorMessage: String?
var showErrorAlert = false var showErrorAlert = false
var currentLabels: [String] = [] var currentLabels: [String] = [] {
didSet {
calculatePages()
}
}
var newLabelText = "" var newLabelText = ""
var allLabels: [BookmarkLabel] = [] { var allLabels: [BookmarkLabel] = [] {
didSet { didSet {
let pageSize = Constants.Labels.pageSize calculatePages()
labelPages = stride(from: 0, to: allLabels.count, by: pageSize).map {
Array(allLabels[$0..<min($0 + pageSize, allLabels.count)])
}
} }
} }
var labelPages: [[BookmarkLabel]] = [] var labelPages: [[BookmarkLabel]] = []
// Computed property for available labels (excluding current labels)
var availableLabels: [BookmarkLabel] {
return allLabels.filter { currentLabels.contains($0.name) == false }
}
var availableLabelPages: [[BookmarkLabel]] = []
init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared, initialLabels: [String] = []) { init(_ factory: UseCaseFactory = DefaultUseCaseFactory.shared, initialLabels: [String] = []) {
self.currentLabels = initialLabels self.currentLabels = initialLabels
@ -35,8 +43,8 @@ class BookmarkLabelsViewModel {
@MainActor @MainActor
func loadAllLabels() async { func loadAllLabels() async {
isLoading = true isInitialLoading = true
defer { isLoading = false } defer { isInitialLoading = false }
do { do {
let labels = try await getLabelsUseCase.execute() let labels = try await getLabelsUseCase.execute()
allLabels = labels allLabels = labels
@ -44,6 +52,8 @@ class BookmarkLabelsViewModel {
errorMessage = "failed to load labels" errorMessage = "failed to load labels"
showErrorAlert = true showErrorAlert = true
} }
calculatePages()
} }
@MainActor @MainActor
@ -52,10 +62,10 @@ class BookmarkLabelsViewModel {
errorMessage = nil errorMessage = nil
do { do {
try await addLabelsUseCase.execute(bookmarkId: bookmarkId, labels: labels)
// Update local labels
currentLabels.append(contentsOf: labels) currentLabels.append(contentsOf: labels)
currentLabels = Array(Set(currentLabels)) // Remove duplicates currentLabels = Array(Set(currentLabels)) // Remove duplicates
try await addLabelsUseCase.execute(bookmarkId: bookmarkId, labels: labels)
} catch let error as BookmarkUpdateError { } catch let error as BookmarkUpdateError {
errorMessage = error.localizedDescription errorMessage = error.localizedDescription
showErrorAlert = true showErrorAlert = true
@ -65,6 +75,7 @@ class BookmarkLabelsViewModel {
} }
isLoading = false isLoading = false
calculatePages()
} }
@MainActor @MainActor
@ -94,6 +105,7 @@ class BookmarkLabelsViewModel {
} }
isLoading = false isLoading = false
calculatePages()
} }
@MainActor @MainActor
@ -109,9 +121,35 @@ class BookmarkLabelsViewModel {
} else { } else {
await addLabel(to: bookmarkId, label: label) await addLabel(to: bookmarkId, label: label)
} }
calculatePages()
} }
func updateLabels(_ labels: [String]) { func updateLabels(_ labels: [String]) {
currentLabels = labels currentLabels = labels
} }
private func calculatePages() {
let pageSize = Constants.Labels.pageSize
// Calculate pages for all labels
if allLabels.count <= pageSize {
labelPages = [allLabels]
} else {
// Normal pagination for larger datasets
labelPages = stride(from: 0, to: allLabels.count, by: pageSize).map {
Array(allLabels[$0..<min($0 + pageSize, allLabels.count)])
}
}
// Calculate pages for available labels (excluding current labels)
if availableLabels.count <= pageSize {
availableLabelPages = [availableLabels]
} else {
// Normal pagination for larger datasets
availableLabelPages = stride(from: 0, to: availableLabels.count, by: pageSize).map {
Array(availableLabels[$0..<min($0 + pageSize, availableLabels.count)])
}
}
}
} }