feat: auto-scroll play button at tab bar level (like Photos search icon)
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:
@@ -28,6 +28,8 @@ struct MainTabView: View {
|
|||||||
@State private var showAssistant = false
|
@State private var showAssistant = false
|
||||||
@State private var confettiTrigger = 0
|
@State private var confettiTrigger = 0
|
||||||
@State private var readerVM = ReaderViewModel()
|
@State private var readerVM = ReaderViewModel()
|
||||||
|
@State private var isAutoScrolling = false
|
||||||
|
@State private var scrollSpeed: Double = 1.0
|
||||||
|
|
||||||
private var showReader: Bool {
|
private var showReader: Bool {
|
||||||
auth.currentUser?.id != 4 // Madiha doesn't see Reader
|
auth.currentUser?.id != 4 // Madiha doesn't see Reader
|
||||||
@@ -45,7 +47,7 @@ struct MainTabView: View {
|
|||||||
.tag(1)
|
.tag(1)
|
||||||
|
|
||||||
if showReader {
|
if showReader {
|
||||||
ReaderTabView(vm: readerVM)
|
ReaderTabView(vm: readerVM, isAutoScrolling: $isAutoScrolling, scrollSpeed: $scrollSpeed)
|
||||||
.tabItem { Label("Reader", systemImage: "newspaper.fill") }
|
.tabItem { Label("Reader", systemImage: "newspaper.fill") }
|
||||||
.tag(2)
|
.tag(2)
|
||||||
}
|
}
|
||||||
@@ -53,16 +55,23 @@ struct MainTabView: View {
|
|||||||
.tint(Color.accentWarm)
|
.tint(Color.accentWarm)
|
||||||
.modifier(TabBarMinimizeModifier())
|
.modifier(TabBarMinimizeModifier())
|
||||||
|
|
||||||
// Floating buttons (hidden on Reader tab)
|
// Floating buttons
|
||||||
if selectedTab != 2 {
|
VStack {
|
||||||
VStack {
|
Spacer()
|
||||||
Spacer()
|
HStack(alignment: .bottom) {
|
||||||
HStack(alignment: .bottom) {
|
if selectedTab != 2 {
|
||||||
FeedbackButton()
|
FeedbackButton()
|
||||||
.padding(.leading, 20)
|
.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: {
|
Button { showAssistant = true } label: {
|
||||||
Image(systemName: "plus")
|
Image(systemName: "plus")
|
||||||
.font(.title2.weight(.semibold))
|
.font(.title2.weight(.semibold))
|
||||||
@@ -74,8 +83,8 @@ struct MainTabView: View {
|
|||||||
}
|
}
|
||||||
.padding(.trailing, 20)
|
.padding(.trailing, 20)
|
||||||
}
|
}
|
||||||
.padding(.bottom, 70)
|
|
||||||
}
|
}
|
||||||
|
.padding(.bottom, 70)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.confettiCannon(
|
.confettiCannon(
|
||||||
@@ -108,6 +117,71 @@ struct MainTabView: View {
|
|||||||
confettiTrigger += 1
|
confettiTrigger += 1
|
||||||
UINotificationFeedbackGenerator().notificationOccurred(.success)
|
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
|
// MARK: - Assistant Sheet
|
||||||
|
|||||||
@@ -2,100 +2,143 @@ import SwiftUI
|
|||||||
|
|
||||||
struct ReaderTabView: View {
|
struct ReaderTabView: View {
|
||||||
@Bindable var vm: ReaderViewModel
|
@Bindable var vm: ReaderViewModel
|
||||||
|
@Binding var isAutoScrolling: Bool
|
||||||
|
@Binding var scrollSpeed: Double
|
||||||
@State private var selectedSubTab = 0
|
@State private var selectedSubTab = 0
|
||||||
@State private var showFeedSheet = false
|
@State private var showFeedSheet = false
|
||||||
@State private var showFeedManagement = false
|
@State private var showFeedManagement = false
|
||||||
@State private var isCardView = true
|
@State private var isCardView = true
|
||||||
@State private var isAutoScrolling = false
|
|
||||||
@State private var scrollSpeed: Double = 1.0
|
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
ZStack(alignment: .bottomTrailing) {
|
VStack(spacing: 0) {
|
||||||
VStack(spacing: 0) {
|
// Sub-tab selector
|
||||||
// Sub-tab selector
|
HStack(spacing: 0) {
|
||||||
HStack(spacing: 0) {
|
ForEach(Array(subTabs.enumerated()), id: \.offset) { index, tab in
|
||||||
ForEach(Array(subTabs.enumerated()), id: \.offset) { index, tab in
|
Button {
|
||||||
Button {
|
withAnimation(.easeInOut(duration: 0.2)) {
|
||||||
withAnimation(.easeInOut(duration: 0.2)) {
|
selectedSubTab = index
|
||||||
selectedSubTab = index
|
switch index {
|
||||||
switch index {
|
case 0: vm.applyFilter(.unread)
|
||||||
case 0: vm.applyFilter(.unread)
|
case 1: vm.applyFilter(.starred)
|
||||||
case 1: vm.applyFilter(.starred)
|
case 2: vm.applyFilter(.all)
|
||||||
case 2: vm.applyFilter(.all)
|
default: break
|
||||||
default: break
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} label: {
|
}
|
||||||
HStack(spacing: 4) {
|
} label: {
|
||||||
Text(tab)
|
HStack(spacing: 4) {
|
||||||
.font(.subheadline.weight(selectedSubTab == index ? .semibold : .regular))
|
Text(tab)
|
||||||
|
.font(.subheadline.weight(selectedSubTab == index ? .semibold : .regular))
|
||||||
|
|
||||||
if index == 0, let counters = vm.counters, counters.totalUnread > 0 {
|
if index == 0, let counters = vm.counters, counters.totalUnread > 0 {
|
||||||
Text("\(counters.totalUnread)")
|
Text("\(counters.totalUnread)")
|
||||||
.font(.caption2.weight(.bold))
|
.font(.caption2.weight(.bold))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.padding(.horizontal, 6)
|
.padding(.horizontal, 6)
|
||||||
.padding(.vertical, 2)
|
.padding(.vertical, 2)
|
||||||
.background(Color.accentWarm)
|
.background(Color.accentWarm)
|
||||||
.clipShape(Capsule())
|
.clipShape(Capsule())
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.foregroundStyle(selectedSubTab == index ? Color.accentWarm : Color.textSecondary)
|
}
|
||||||
.padding(.vertical, 10)
|
.foregroundStyle(selectedSubTab == index ? Color.accentWarm : Color.textSecondary)
|
||||||
.padding(.horizontal, 16)
|
.padding(.vertical, 10)
|
||||||
.background {
|
.padding(.horizontal, 16)
|
||||||
if selectedSubTab == index {
|
.background {
|
||||||
Capsule()
|
if selectedSubTab == index {
|
||||||
.fill(Color.accentWarm.opacity(0.12))
|
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(.horizontal)
|
||||||
.padding(.top, 8)
|
.padding(.vertical, 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)
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
.frame(height: 44)
|
||||||
.background(Color.canvas)
|
|
||||||
|
|
||||||
// MARK: - Liquid Glass Control Bar
|
// Entry list
|
||||||
glassControlBar
|
EntryListView(vm: vm, isCardView: isCardView, isAutoScrolling: $isAutoScrolling, scrollSpeed: scrollSpeed)
|
||||||
.padding(.trailing, 16)
|
|
||||||
.padding(.bottom, 4)
|
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||||
|
.background(Color.canvas)
|
||||||
.navigationBarHidden(true)
|
.navigationBarHidden(true)
|
||||||
.sheet(isPresented: $showFeedSheet) {
|
.sheet(isPresented: $showFeedSheet) {
|
||||||
AddFeedSheet(vm: vm)
|
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
|
// MARK: - Helpers
|
||||||
|
|
||||||
private var subTabs: [String] { ["Unread", "Starred", "All"] }
|
private var subTabs: [String] { ["Unread", "Starred", "All"] }
|
||||||
|
|||||||
Reference in New Issue
Block a user