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

View File

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

View File

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

View File

@ -49,7 +49,10 @@ class BookmarkDetailViewModel {
do {
settings = try await loadSettingsUseCase.execute()
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 {
self.addTextToSpeechQueueUseCase = factory?.makeAddTextToSpeechQueueUseCase()
@ -121,10 +124,13 @@ class BookmarkDetailViewModel {
}
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
// Only update if the new progress is higher than current
if progress > readProgress {
do {
try await updateBookmarkUseCase.updateReadProgress(bookmarkId: id, progress: progress, anchor: anchor)
} catch {
// ignore error in this case
}
}
}

View File

@ -42,21 +42,48 @@ struct BookmarkLabelsView: View {
.padding(.horizontal)
// All available labels
if !viewModel.allLabels.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("All available tags")
.font(.headline)
.padding(.horizontal)
VStack(alignment: .leading, spacing: 8) {
Text("All available tags")
.font(.headline)
.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 {
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) {
ForEach(labelsPage.filter { !viewModel.currentLabels.contains($0.name) }, id: \ .id) { label in
ForEach(labelsPage, id: \.id) { label in
UnifiedLabelChip(
label: label.name,
isSelected: viewModel.currentLabels.contains(label.name),
isRemovable: false,
onTap: {
print("addLabelsUseCase")
Task {
await viewModel.toggleLabel(for: bookmarkId, label: label.name)
}
@ -68,7 +95,7 @@ struct BookmarkLabelsView: View {
.padding(.horizontal)
}
}
.tabViewStyle(PageTabViewStyle(indexDisplayMode: .automatic))
.tabViewStyle(.page(indexDisplayMode: viewModel.availableLabelPages.count > 1 ? .automatic : .never))
.frame(height: 180)
.padding(.top, -20)
}
@ -77,7 +104,7 @@ struct BookmarkLabelsView: View {
// Current labels
if !viewModel.currentLabels.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text("Current labels")
Text("Current tags")
.font(.headline)
.padding(.horizontal)

View File

@ -7,23 +7,31 @@ class BookmarkLabelsViewModel {
private let getLabelsUseCase: PGetLabelsUseCase
var isLoading = false
var isInitialLoading = false
var errorMessage: String?
var showErrorAlert = false
var currentLabels: [String] = []
var currentLabels: [String] = [] {
didSet {
calculatePages()
}
}
var newLabelText = ""
var allLabels: [BookmarkLabel] = [] {
didSet {
let pageSize = Constants.Labels.pageSize
labelPages = stride(from: 0, to: allLabels.count, by: pageSize).map {
Array(allLabels[$0..<min($0 + pageSize, allLabels.count)])
}
calculatePages()
}
}
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] = []) {
self.currentLabels = initialLabels
@ -35,8 +43,8 @@ class BookmarkLabelsViewModel {
@MainActor
func loadAllLabels() async {
isLoading = true
defer { isLoading = false }
isInitialLoading = true
defer { isInitialLoading = false }
do {
let labels = try await getLabelsUseCase.execute()
allLabels = labels
@ -44,6 +52,8 @@ class BookmarkLabelsViewModel {
errorMessage = "failed to load labels"
showErrorAlert = true
}
calculatePages()
}
@MainActor
@ -52,10 +62,10 @@ class BookmarkLabelsViewModel {
errorMessage = nil
do {
try await addLabelsUseCase.execute(bookmarkId: bookmarkId, labels: labels)
// Update local labels
currentLabels.append(contentsOf: labels)
currentLabels = Array(Set(currentLabels)) // Remove duplicates
try await addLabelsUseCase.execute(bookmarkId: bookmarkId, labels: labels)
} catch let error as BookmarkUpdateError {
errorMessage = error.localizedDescription
showErrorAlert = true
@ -65,6 +75,7 @@ class BookmarkLabelsViewModel {
}
isLoading = false
calculatePages()
}
@MainActor
@ -94,6 +105,7 @@ class BookmarkLabelsViewModel {
}
isLoading = false
calculatePages()
}
@MainActor
@ -109,9 +121,35 @@ class BookmarkLabelsViewModel {
} else {
await addLabel(to: bookmarkId, label: label)
}
calculatePages()
}
func updateLabels(_ labels: [String]) {
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)])
}
}
}
}