Add comprehensive annotations feature to bookmark detail views: - Implement annotations list view with date formatting and state machine - Add CSS-based highlighting for rd-annotation tags in WebView components - Support Readeck color scheme (yellow, green, blue, red) for annotations - Enable tap-to-scroll functionality to navigate to selected annotations - Integrate annotations button in bookmark detail toolbar - Add API endpoint and repository layer for fetching annotations
282 lines
10 KiB
Swift
282 lines
10 KiB
Swift
//
|
|
// PhoneTabView.swift
|
|
// readeck
|
|
//
|
|
// Created by Ilyas Hallak on 01.07.25.
|
|
//
|
|
|
|
import SwiftUI
|
|
|
|
struct PhoneTabView: View {
|
|
private let mainTabs: [SidebarTab] = [.all, .unread, .favorite, .archived]
|
|
private let moreTabs: [SidebarTab] = [.article, .videos, .pictures, .tags, .settings]
|
|
|
|
@State private var selectedTab: SidebarTab = .unread
|
|
@State private var offlineBookmarksViewModel = OfflineBookmarksViewModel()
|
|
|
|
// Navigation paths for each tab
|
|
@State private var allPath = NavigationPath()
|
|
@State private var unreadPath = NavigationPath()
|
|
@State private var favoritePath = NavigationPath()
|
|
@State private var archivedPath = NavigationPath()
|
|
@State private var morePath = NavigationPath()
|
|
|
|
// Search functionality
|
|
@State private var searchViewModel = SearchBookmarksViewModel()
|
|
@FocusState private var searchFieldIsFocused: Bool
|
|
|
|
@EnvironmentObject var appSettings: AppSettings
|
|
|
|
private var cardLayoutStyle: CardLayoutStyle {
|
|
appSettings.settings?.cardLayoutStyle ?? .compact
|
|
}
|
|
|
|
private var offlineBookmarksBadgeCount: Int {
|
|
offlineBookmarksViewModel.state.localBookmarkCount > 0 ? offlineBookmarksViewModel.state.localBookmarkCount : 0
|
|
}
|
|
|
|
var body: some View {
|
|
GlobalPlayerContainerView {
|
|
TabView(selection: $selectedTab) {
|
|
|
|
Tab(value: SidebarTab.all) {
|
|
NavigationStack(path: $allPath) {
|
|
tabView(for: .all)
|
|
}
|
|
} label: {
|
|
Label(SidebarTab.all.label, systemImage: SidebarTab.all.systemImage)
|
|
}
|
|
|
|
Tab(value: SidebarTab.unread) {
|
|
NavigationStack(path: $unreadPath) {
|
|
tabView(for: .unread)
|
|
}
|
|
} label: {
|
|
Label(SidebarTab.unread.label, systemImage: SidebarTab.unread.systemImage)
|
|
}
|
|
|
|
Tab(value: SidebarTab.favorite) {
|
|
NavigationStack(path: $favoritePath) {
|
|
tabView(for: .favorite)
|
|
}
|
|
} label: {
|
|
Label(SidebarTab.favorite.label, systemImage: SidebarTab.favorite.systemImage)
|
|
}
|
|
|
|
Tab(value: SidebarTab.archived) {
|
|
NavigationStack(path: $archivedPath) {
|
|
tabView(for: .archived)
|
|
}
|
|
} label: {
|
|
Label(SidebarTab.archived.label, systemImage: SidebarTab.archived.systemImage)
|
|
}
|
|
|
|
// iOS 26+: Dedicated search tab with role
|
|
if #available(iOS 26, *) {
|
|
Tab("Search", systemImage: SidebarTab.search.systemImage, value: SidebarTab.search, role: .search) {
|
|
NavigationStack {
|
|
moreTabContent
|
|
.searchable(text: $searchViewModel.searchQuery, prompt: "Search bookmarks...")
|
|
}
|
|
}
|
|
.badge(offlineBookmarksBadgeCount)
|
|
} else {
|
|
Tab(value: SidebarTab.settings) {
|
|
NavigationStack(path: $morePath) {
|
|
VStack(spacing: 0) {
|
|
|
|
// Classic search bar for iOS 18
|
|
HStack {
|
|
Image(systemName: "magnifyingglass")
|
|
.foregroundColor(.gray)
|
|
TextField("Search...", text: $searchViewModel.searchQuery)
|
|
.focused($searchFieldIsFocused)
|
|
.textFieldStyle(PlainTextFieldStyle())
|
|
.autocapitalization(.none)
|
|
.disableAutocorrection(true)
|
|
if !searchViewModel.searchQuery.isEmpty {
|
|
Button(action: {
|
|
searchViewModel.searchQuery = ""
|
|
searchFieldIsFocused = true
|
|
}) {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.foregroundColor(.gray)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
.padding(10)
|
|
.background(Color(.systemGray6))
|
|
.cornerRadius(12)
|
|
.padding([.horizontal, .top])
|
|
|
|
moreTabContent
|
|
moreTabsFooter
|
|
}
|
|
.navigationTitle("More")
|
|
}
|
|
} label: {
|
|
Label("More", systemImage: "ellipsis")
|
|
}
|
|
.badge(offlineBookmarksBadgeCount)
|
|
}
|
|
}
|
|
.tabBarMinimizeBehaviorIfAvailable()
|
|
.accentColor(.accentColor)
|
|
.searchToolbarBehaviorIfAvailable()
|
|
}
|
|
}
|
|
|
|
// MARK: - Tab Content
|
|
|
|
@ViewBuilder
|
|
private var moreTabContent: some View {
|
|
if searchViewModel.searchQuery.isEmpty {
|
|
moreTabsList
|
|
} else {
|
|
searchResultsView
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var searchResultsView: some View {
|
|
if searchViewModel.isLoading {
|
|
ProgressView("Searching...")
|
|
.padding()
|
|
} else if let error = searchViewModel.errorMessage {
|
|
Text(error)
|
|
.foregroundColor(.red)
|
|
.padding()
|
|
} else if let bookmarks = searchViewModel.bookmarks?.bookmarks, !bookmarks.isEmpty {
|
|
List(bookmarks) { bookmark in
|
|
ZStack {
|
|
|
|
// Hidden NavigationLink to remove disclosure indicator
|
|
NavigationLink {
|
|
BookmarkDetailView(bookmarkId: bookmark.id)
|
|
} label: {
|
|
EmptyView()
|
|
}
|
|
.opacity(0)
|
|
|
|
BookmarkCardView(
|
|
bookmark: bookmark,
|
|
currentState: .all,
|
|
layout: cardLayoutStyle,
|
|
onArchive: { _ in },
|
|
onDelete: { _ in },
|
|
onToggleFavorite: { _ in }
|
|
)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.listRowInsets(EdgeInsets(
|
|
top: cardLayoutStyle == .compact ? 8 : 12,
|
|
leading: 16,
|
|
bottom: cardLayoutStyle == .compact ? 8 : 12,
|
|
trailing: 16
|
|
))
|
|
.listRowSeparator(.hidden)
|
|
.listRowBackground(Color(R.color.bookmark_list_bg))
|
|
}
|
|
.scrollContentBackground(.hidden)
|
|
.background(Color(R.color.bookmark_list_bg))
|
|
.listStyle(.plain)
|
|
} else if searchViewModel.searchQuery.isEmpty == false {
|
|
ContentUnavailableView("No results", systemImage: "magnifyingglass", description: Text("No bookmarks found."))
|
|
.padding()
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var moreTabsList: some View {
|
|
List {
|
|
ForEach(moreTabs, id: \.self) { tab in
|
|
NavigationLink {
|
|
tabView(for: tab)
|
|
.navigationTitle(tab.label)
|
|
.navigationBarTitleDisplayMode(.large)
|
|
} label: {
|
|
Label(tab.label, systemImage: tab.systemImage)
|
|
}
|
|
.listRowBackground(Color(R.color.bookmark_list_bg))
|
|
}
|
|
|
|
if case .idle = offlineBookmarksViewModel.state {
|
|
// Don't show anything for idle state
|
|
} else {
|
|
Section {
|
|
VStack {
|
|
LocalBookmarksSyncView(state: offlineBookmarksViewModel.state) {
|
|
await offlineBookmarksViewModel.syncOfflineBookmarks()
|
|
}
|
|
}
|
|
.listRowBackground(Color.clear)
|
|
.listRowInsets(EdgeInsets())
|
|
}
|
|
}
|
|
}
|
|
.navigationTitle("More")
|
|
.scrollContentBackground(.hidden)
|
|
.background(Color(R.color.bookmark_list_bg))
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var moreTabsFooter: some View {
|
|
if appSettings.enableTTS {
|
|
PlayerQueueResumeButton()
|
|
.padding(.top, 16)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func tabView(for tab: SidebarTab) -> some View {
|
|
switch tab {
|
|
case .all:
|
|
BookmarksView(state: .all, type: [.article, .video, .photo], selectedBookmark: .constant(nil))
|
|
case .unread:
|
|
BookmarksView(state: .unread, type: [.article, .video, .photo], selectedBookmark: .constant(nil))
|
|
case .favorite:
|
|
BookmarksView(state: .favorite, type: [.article, .video, .photo], selectedBookmark: .constant(nil))
|
|
case .archived:
|
|
BookmarksView(state: .archived, type: [.article, .video, .photo], selectedBookmark: .constant(nil))
|
|
case .search:
|
|
EmptyView() // search is directly implemented
|
|
case .settings:
|
|
SettingsView()
|
|
case .article:
|
|
BookmarksView(state: .all, type: [.article], selectedBookmark: .constant(nil))
|
|
case .videos:
|
|
BookmarksView(state: .all, type: [.video], selectedBookmark: .constant(nil))
|
|
case .pictures:
|
|
BookmarksView(state: .all, type: [.photo], selectedBookmark: .constant(nil))
|
|
case .tags:
|
|
LabelsView(selectedTag: .constant(nil))
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
|
|
// MARK: - View Extension for iOS 26+ Compatibility
|
|
extension View {
|
|
@ViewBuilder
|
|
func searchToolbarBehaviorIfAvailable() -> some View {
|
|
if #available(iOS 26, *) {
|
|
self
|
|
.searchToolbarBehavior(.minimize)
|
|
} else {
|
|
self
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
func tabBarMinimizeBehaviorIfAvailable() -> some View {
|
|
if #available(iOS 26.0, *) {
|
|
self
|
|
.tabBarMinimizeBehavior(.onScrollDown)
|
|
} else {
|
|
self
|
|
}
|
|
}
|
|
}
|