From f17279d5b81eae0d0e8a9ec2d64dd383d9ce678c Mon Sep 17 00:00:00 2001 From: Yusuf Suleman Date: Fri, 3 Apr 2026 20:28:19 -0500 Subject: [PATCH] fix: mark-as-read only on scroll (not navigation), Goals keyboard dismiss 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) --- .../Features/Fitness/Views/GoalsView.swift | 4 ++ .../Features/Reader/Views/EntryListView.swift | 37 ++++++++++++------- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/ios/Platform/Platform/Features/Fitness/Views/GoalsView.swift b/ios/Platform/Platform/Features/Fitness/Views/GoalsView.swift index f1a47f0..5b2b57a 100644 --- a/ios/Platform/Platform/Features/Fitness/Views/GoalsView.swift +++ b/ios/Platform/Platform/Features/Fitness/Views/GoalsView.swift @@ -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() } diff --git a/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift b/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift index f5e9a14..e1f0a40 100644 --- a/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift +++ b/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift @@ -3,6 +3,8 @@ import SwiftUI struct EntryListView: View { @Bindable var vm: ReaderViewModel var isCardView: Bool = true + @State private var visibleEntryIDs: Set = [] + @State private var readByScrollIDs: Set = [] 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 {