diff --git a/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift b/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift index b314e3e..8367970 100644 --- a/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift +++ b/ios/Platform/Platform/Features/Reader/Views/EntryListView.swift @@ -111,15 +111,13 @@ struct EntryListView: View { // MARK: - Entry Row with Scroll-Read Tracking private func scrollTracked(_ entry: ReaderEntry, content: some View) -> some View { - content + let debugThis = vm.entries.prefix(3).contains(where: { $0.id == entry.id }) + return content .background( GeometryReader { geo in let frame = geo.frame(in: .named("readerScroll")) Color.clear .onChange(of: frame.minY) { _, _ in - // Track visibility: if >=50% of the entry is within the viewport, - // record it as "was visible". Only entries that were genuinely seen - // can later be marked as read on scroll-past. let entryHeight = frame.height guard entryHeight > 0 else { return } let visibleTop = max(frame.minY, 0) @@ -130,27 +128,36 @@ struct EntryListView: View { if visibleRatio >= 0.5 { wasVisible.insert(entry.id) } + + if debugThis { + print("[SCROLL-VIS] id=\(entry.id) minY=\(Int(frame.minY)) maxY=\(Int(frame.maxY)) height=\(Int(entryHeight)) visH=\(Int(visibleHeight)) ratio=\(String(format:"%.2f", visibleRatio)) vpH=\(Int(viewportHeight)) wasVis=\(wasVisible.contains(entry.id))") + } } .onChange(of: frame.maxY) { _, newMaxY in - // Mark as read when entry is above viewport. - // 5 conditions must be true: - // 1. Tracking active (scrolled past dynamic threshold) - // 2. Scrolling downward - // 3. Entry is unread - // 4. Not already marked by scroll - // 5. Was >=50% visible at some point (genuinely seen) - // - // We check newMaxY < 0 (entry fully above viewport) - // instead of requiring an exact crossing event, because - // LazyVStack can recycle the view between onChange calls, - // causing the 0-boundary crossing to never be delivered. + if debugThis { + print("[SCROLL-MRK] id=\(entry.id) maxY=\(Int(newMaxY)) active=\(trackingActive) down=\(isScrollingDown) read=\(entry.isRead) marked=\(markedByScroll.contains(entry.id)) wasVis=\(wasVisible.contains(entry.id)) cumDown=\(Int(cumulativeDown)) thresh=\(Int(activationThreshold))") + } + guard trackingActive, isScrollingDown, !entry.isRead, !markedByScroll.contains(entry.id), wasVisible.contains(entry.id), - newMaxY < 0 else { return } + newMaxY < 0 else { + if debugThis && newMaxY < 0 { + // Log WHY it failed when entry IS above viewport + var reasons: [String] = [] + if !trackingActive { reasons.append("tracking-inactive") } + if !isScrollingDown { reasons.append("not-scrolling-down") } + if entry.isRead { reasons.append("already-read") } + if markedByScroll.contains(entry.id) { reasons.append("already-marked") } + if !wasVisible.contains(entry.id) { reasons.append("never-visible") } + print("[SCROLL-FAIL] id=\(entry.id) maxY=\(Int(newMaxY)) reasons=\(reasons.joined(separator: ","))") + } + return + } + print("[SCROLL-READ] ✅ id=\(entry.id) MARKED AS READ") markedByScroll.insert(entry.id) // Local-first: update immediately