feat: auto-scroll play button at tab bar level (like Photos search icon)
All checks were successful
Security Checks / dependency-audit (push) Successful in 13s
Security Checks / secret-scanning (push) Successful in 4s
Security Checks / dockerfile-lint (push) Successful in 4s

Moved auto-scroll control from ReaderTabView to MainTabView so it
sits at the tab bar level, trailing side — matching the iOS Photos
search icon placement.

Idle: 48px glass circle with play icon (bottom-right, tab bar row)
Playing: expands to capsule with [ - ] 1.00x [ + ] [ stop ]
Spring animation between states.

Grid/list toggle and ellipsis menu moved inline to the sub-tab
header row (next to Unread/Starred/All) so they're always visible
without needing a toolbar.

ReaderTabView now receives isAutoScrolling and scrollSpeed as
bindings from MainTabView.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yusuf Suleman
2026-04-04 07:28:12 -05:00
parent 7815f56b4f
commit 5d2262e17a
2 changed files with 201 additions and 206 deletions

View File

@@ -28,6 +28,8 @@ struct MainTabView: View {
@State private var showAssistant = false
@State private var confettiTrigger = 0
@State private var readerVM = ReaderViewModel()
@State private var isAutoScrolling = false
@State private var scrollSpeed: Double = 1.0
private var showReader: Bool {
auth.currentUser?.id != 4 // Madiha doesn't see Reader
@@ -45,7 +47,7 @@ struct MainTabView: View {
.tag(1)
if showReader {
ReaderTabView(vm: readerVM)
ReaderTabView(vm: readerVM, isAutoScrolling: $isAutoScrolling, scrollSpeed: $scrollSpeed)
.tabItem { Label("Reader", systemImage: "newspaper.fill") }
.tag(2)
}
@@ -53,16 +55,23 @@ struct MainTabView: View {
.tint(Color.accentWarm)
.modifier(TabBarMinimizeModifier())
// Floating buttons (hidden on Reader tab)
if selectedTab != 2 {
VStack {
Spacer()
HStack(alignment: .bottom) {
// Floating buttons
VStack {
Spacer()
HStack(alignment: .bottom) {
if selectedTab != 2 {
FeedbackButton()
.padding(.leading, 20)
}
Spacer()
Spacer()
if selectedTab == 2 {
// Reader: auto-scroll control at tab bar level
readerAutoScrollPill
.padding(.trailing, 8)
.padding(.bottom, 0)
} else {
Button { showAssistant = true } label: {
Image(systemName: "plus")
.font(.title2.weight(.semibold))
@@ -74,8 +83,8 @@ struct MainTabView: View {
}
.padding(.trailing, 20)
}
.padding(.bottom, 70)
}
.padding(.bottom, 70)
}
}
.confettiCannon(
@@ -108,6 +117,71 @@ struct MainTabView: View {
confettiTrigger += 1
UINotificationFeedbackGenerator().notificationOccurred(.success)
}
// MARK: - Reader Auto-Scroll Pill (tab bar level)
private var readerAutoScrollPill: some View {
Group {
if isAutoScrolling {
// Expanded: speed + stop
HStack(spacing: 8) {
Button {
scrollSpeed = max(0.25, scrollSpeed - 0.25)
} label: {
Image(systemName: "minus")
.font(.caption2.weight(.bold))
.foregroundStyle(Color.accentWarm)
.frame(width: 28, height: 28)
}
Text(String(format: "%.2fx", scrollSpeed))
.font(.system(size: 11, weight: .bold, design: .monospaced))
.foregroundStyle(Color.textPrimary)
Button {
scrollSpeed = min(3.0, scrollSpeed + 0.25)
} label: {
Image(systemName: "plus")
.font(.caption2.weight(.bold))
.foregroundStyle(Color.accentWarm)
.frame(width: 28, height: 28)
}
Button {
withAnimation(.spring(duration: 0.3)) {
isAutoScrolling = false
}
} label: {
Image(systemName: "stop.fill")
.font(.system(size: 11))
.foregroundStyle(.white)
.frame(width: 32, height: 32)
.background(Color.red.opacity(0.8))
.clipShape(Circle())
}
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(.ultraThinMaterial, in: Capsule())
.transition(.scale(scale: 0.5).combined(with: .opacity))
} else {
// Collapsed: play button circle (like Photos search icon)
Button {
withAnimation(.spring(duration: 0.3)) {
isAutoScrolling = true
}
} label: {
Image(systemName: "play.fill")
.font(.system(size: 15))
.foregroundStyle(Color.accentWarm)
.frame(width: 48, height: 48)
.background(.ultraThinMaterial, in: Circle())
}
.transition(.scale(scale: 0.5).combined(with: .opacity))
}
}
.animation(.spring(duration: 0.3), value: isAutoScrolling)
}
}
// MARK: - Assistant Sheet

View File

@@ -2,100 +2,143 @@ import SwiftUI
struct ReaderTabView: View {
@Bindable var vm: ReaderViewModel
@Binding var isAutoScrolling: Bool
@Binding var scrollSpeed: Double
@State private var selectedSubTab = 0
@State private var showFeedSheet = false
@State private var showFeedManagement = false
@State private var isCardView = true
@State private var isAutoScrolling = false
@State private var scrollSpeed: Double = 1.0
var body: some View {
NavigationStack {
ZStack(alignment: .bottomTrailing) {
VStack(spacing: 0) {
// Sub-tab selector
HStack(spacing: 0) {
ForEach(Array(subTabs.enumerated()), id: \.offset) { index, tab in
Button {
withAnimation(.easeInOut(duration: 0.2)) {
selectedSubTab = index
switch index {
case 0: vm.applyFilter(.unread)
case 1: vm.applyFilter(.starred)
case 2: vm.applyFilter(.all)
default: break
}
VStack(spacing: 0) {
// Sub-tab selector
HStack(spacing: 0) {
ForEach(Array(subTabs.enumerated()), id: \.offset) { index, tab in
Button {
withAnimation(.easeInOut(duration: 0.2)) {
selectedSubTab = index
switch index {
case 0: vm.applyFilter(.unread)
case 1: vm.applyFilter(.starred)
case 2: vm.applyFilter(.all)
default: break
}
} label: {
HStack(spacing: 4) {
Text(tab)
.font(.subheadline.weight(selectedSubTab == index ? .semibold : .regular))
}
} label: {
HStack(spacing: 4) {
Text(tab)
.font(.subheadline.weight(selectedSubTab == index ? .semibold : .regular))
if index == 0, let counters = vm.counters, counters.totalUnread > 0 {
Text("\(counters.totalUnread)")
.font(.caption2.weight(.bold))
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.accentWarm)
.clipShape(Capsule())
}
if index == 0, let counters = vm.counters, counters.totalUnread > 0 {
Text("\(counters.totalUnread)")
.font(.caption2.weight(.bold))
.foregroundStyle(.white)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color.accentWarm)
.clipShape(Capsule())
}
.foregroundStyle(selectedSubTab == index ? Color.accentWarm : Color.textSecondary)
.padding(.vertical, 10)
.padding(.horizontal, 16)
.background {
if selectedSubTab == index {
Capsule()
.fill(Color.accentWarm.opacity(0.12))
}
}
.foregroundStyle(selectedSubTab == index ? Color.accentWarm : Color.textSecondary)
.padding(.vertical, 10)
.padding(.horizontal, 16)
.background {
if selectedSubTab == index {
Capsule()
.fill(Color.accentWarm.opacity(0.12))
}
}
}
}
Spacer()
// Inline controls: grid/list + more menu
HStack(spacing: 4) {
Button {
withAnimation(.easeInOut(duration: 0.2)) {
isCardView.toggle()
}
} label: {
Image(systemName: isCardView ? "list.bullet" : "square.grid.2x2")
.font(.subheadline)
.foregroundStyle(Color.accentWarm)
.frame(width: 32, height: 32)
}
Menu {
Button {
Task { await vm.markAllRead() }
} label: {
Label("Mark All Read", systemImage: "checkmark.circle")
}
Button {
Task { await vm.refresh() }
} label: {
Label("Refresh Feeds", systemImage: "arrow.clockwise")
}
Divider()
Button {
showFeedManagement = true
} label: {
Label("Manage Feeds", systemImage: "list.bullet")
}
Button {
showFeedSheet = true
} label: {
Label("Add Feed", systemImage: "plus")
}
} label: {
Image(systemName: "ellipsis")
.font(.subheadline)
.foregroundStyle(Color.accentWarm)
.frame(width: 32, height: 32)
}
}
.padding(.trailing, 8)
}
.padding(.leading)
.padding(.top, 8)
// Feed filter bar
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
feedFilterChip("All", isSelected: isAllSelected) {
let tab = selectedSubTab
switch tab {
case 0: vm.applyFilter(.unread)
case 1: vm.applyFilter(.starred)
case 2: vm.applyFilter(.all)
default: vm.applyFilter(.unread)
}
}
ForEach(vm.feeds) { feed in
let count = vm.counters?.count(forFeed: feed.id) ?? 0
feedFilterChip(
feed.title,
count: selectedSubTab == 0 ? count : nil,
isSelected: vm.currentFilter == .feed(feed.id)
) {
vm.applyFilter(.feed(feed.id))
}
}
}
.padding(.horizontal)
.padding(.top, 8)
// Feed filter bar
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
feedFilterChip("All", isSelected: isAllSelected) {
let tab = selectedSubTab
switch tab {
case 0: vm.applyFilter(.unread)
case 1: vm.applyFilter(.starred)
case 2: vm.applyFilter(.all)
default: vm.applyFilter(.unread)
}
}
ForEach(vm.feeds) { feed in
let count = vm.counters?.count(forFeed: feed.id) ?? 0
feedFilterChip(
feed.title,
count: selectedSubTab == 0 ? count : nil,
isSelected: vm.currentFilter == .feed(feed.id)
) {
vm.applyFilter(.feed(feed.id))
}
}
}
.padding(.horizontal)
.padding(.vertical, 8)
}
.frame(height: 44)
// Entry list
EntryListView(vm: vm, isCardView: isCardView, isAutoScrolling: $isAutoScrolling, scrollSpeed: scrollSpeed)
.padding(.vertical, 8)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.canvas)
.frame(height: 44)
// MARK: - Liquid Glass Control Bar
glassControlBar
.padding(.trailing, 16)
.padding(.bottom, 4)
// Entry list
EntryListView(vm: vm, isCardView: isCardView, isAutoScrolling: $isAutoScrolling, scrollSpeed: scrollSpeed)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.canvas)
.navigationBarHidden(true)
.sheet(isPresented: $showFeedSheet) {
AddFeedSheet(vm: vm)
@@ -115,128 +158,6 @@ struct ReaderTabView: View {
}
}
// MARK: - Glass Control Bar
private var glassControlBar: some View {
HStack(spacing: 0) {
if isAutoScrolling {
// Expanded: speed controls
HStack(spacing: 12) {
Button {
scrollSpeed = max(0.25, scrollSpeed - 0.25)
} label: {
Image(systemName: "minus")
.font(.caption.weight(.bold))
.foregroundStyle(Color.accentWarm)
.frame(width: 32, height: 32)
}
Text(String(format: "%.2fx", scrollSpeed))
.font(.caption.weight(.bold).monospacedDigit())
.foregroundStyle(Color.textPrimary)
.frame(width: 44)
Button {
scrollSpeed = min(3.0, scrollSpeed + 0.25)
} label: {
Image(systemName: "plus")
.font(.caption.weight(.bold))
.foregroundStyle(Color.accentWarm)
.frame(width: 32, height: 32)
}
Divider()
.frame(height: 20)
Button {
withAnimation(.spring(duration: 0.3)) {
isAutoScrolling = false
}
} label: {
Image(systemName: "stop.fill")
.font(.caption)
.foregroundStyle(.red)
.frame(width: 32, height: 32)
}
}
.transition(.asymmetric(
insertion: .scale(scale: 0.8).combined(with: .opacity),
removal: .scale(scale: 0.8).combined(with: .opacity)
))
} else {
// Collapsed: action buttons
HStack(spacing: 4) {
// Auto-scroll play
Button {
withAnimation(.spring(duration: 0.3)) {
isAutoScrolling = true
}
} label: {
Image(systemName: "play.fill")
.font(.caption)
.foregroundStyle(Color.accentWarm)
.frame(width: 36, height: 36)
}
// Card/list toggle
Button {
withAnimation(.easeInOut(duration: 0.2)) {
isCardView.toggle()
}
} label: {
Image(systemName: isCardView ? "list.bullet" : "square.grid.2x2")
.font(.caption)
.foregroundStyle(Color.accentWarm)
.frame(width: 36, height: 36)
}
// More menu
Menu {
Button {
Task { await vm.markAllRead() }
} label: {
Label("Mark All Read", systemImage: "checkmark.circle")
}
Button {
Task { await vm.refresh() }
} label: {
Label("Refresh Feeds", systemImage: "arrow.clockwise")
}
Divider()
Button {
showFeedManagement = true
} label: {
Label("Manage Feeds", systemImage: "list.bullet")
}
Button {
showFeedSheet = true
} label: {
Label("Add Feed", systemImage: "plus")
}
} label: {
Image(systemName: "ellipsis")
.font(.caption)
.foregroundStyle(Color.accentWarm)
.frame(width: 36, height: 36)
}
}
.transition(.asymmetric(
insertion: .scale(scale: 0.8).combined(with: .opacity),
removal: .scale(scale: 0.8).combined(with: .opacity)
))
}
}
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(.ultraThinMaterial, in: Capsule())
.shadow(color: .black.opacity(0.1), radius: 8, y: 2)
.animation(.spring(duration: 0.3), value: isAutoScrolling)
}
// MARK: - Helpers
private var subTabs: [String] { ["Unread", "Starred", "All"] }