fix: mark-as-read only on scroll (not navigation), Goals keyboard dismiss
All checks were successful
Security Checks / dockerfile-lint (push) Successful in 3s
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s

Reader mark-as-read:
- Track visible entry IDs with onAppear/onDisappear
- Only mark as read when entry disappears AND other entries are still
  visible (meaning user is scrolling, not navigating to an article)
- Prevents the bug where opening an article marked all visible entries

Goals (#11):
- Added .scrollDismissesKeyboard(.interactively) for drag-to-dismiss
- Added tap-to-dismiss keyboard on background

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-03 20:28:19 -05:00
parent 028e308588
commit f17279d5b8
2 changed files with 28 additions and 13 deletions

View File

@@ -24,6 +24,10 @@ struct GoalsView: View {
.padding(.top, 8)
}
.background(Color.canvas)
.scrollDismissesKeyboard(.interactively)
.onTapGesture {
UIApplication.shared.sendAction(#selector(UIResponder.resignFirstResponder), to: nil, from: nil, for: nil)
}
.task {
await vm.load()
}

View File

@@ -3,6 +3,8 @@ import SwiftUI
struct EntryListView: View {
@Bindable var vm: ReaderViewModel
var isCardView: Bool = true
@State private var visibleEntryIDs: Set<Int> = []
@State private var readByScrollIDs: Set<Int> = []
var body: some View {
if vm.isLoading && vm.entries.isEmpty {
@@ -42,12 +44,8 @@ struct EntryListView: View {
}
.buttonStyle(.plain)
.contentShape(Rectangle())
.onDisappear {
// Mark as read when card scrolls off the top
if !entry.isRead {
Task { await vm.markAsRead(entry) }
}
}
.onAppear { visibleEntryIDs.insert(entry.id) }
.onDisappear { markReadIfScrolled(entry) }
.contextMenu {
entryContextMenu(entry: entry, vm: vm)
}
@@ -71,11 +69,8 @@ struct EntryListView: View {
}
.buttonStyle(.plain)
.contentShape(Rectangle())
.onDisappear {
if !entry.isRead {
Task { await vm.markAsRead(entry) }
}
}
.onAppear { visibleEntryIDs.insert(entry.id) }
.onDisappear { markReadIfScrolled(entry) }
.contextMenu {
entryContextMenu(entry: entry, vm: vm)
}
@@ -90,6 +85,24 @@ struct EntryListView: View {
}
}
// MARK: - Mark as read on scroll
/// Only mark as read if the entry was visible before AND has scrolled off
/// while other entries are still visible (i.e. user is scrolling, not navigating away)
private func markReadIfScrolled(_ entry: ReaderEntry) {
// Remove from visible set
visibleEntryIDs.remove(entry.id)
// If there are still visible entries, user is scrolling (not navigating)
// and this entry scrolled off mark as read
guard !visibleEntryIDs.isEmpty else { return }
guard !entry.isRead else { return }
guard !readByScrollIDs.contains(entry.id) else { return }
readByScrollIDs.insert(entry.id)
Task { await vm.markAsRead(entry) }
}
private var loadMoreTrigger: some View {
Group {
if vm.isLoadingMore {
@@ -113,7 +126,6 @@ struct EntryCardView: View {
var body: some View {
VStack(alignment: .leading, spacing: 0) {
// Thumbnail
if let thumbURL = entry.thumbnailURL {
AsyncImage(url: thumbURL) { phase in
switch phase {
@@ -129,7 +141,6 @@ struct EntryCardView: View {
}
}
// Content
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
if !entry.isRead {