From fe66acbd39434a026f77f47579e13764e2e60f8d Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Sat, 30 Dec 2023 14:54:09 +0100 Subject: [PATCH] Timeline: Add pills quick access --- IceCubesApp/App/AppRegistry.swift | 3 + .../App/Tabs/Timeline/TimelineTab.swift | 229 +++++++++++------- .../Sources/Timeline/TimelineFilter.swift | 11 +- .../View/TimelineQuickAccessPills.swift | 100 ++++++++ .../Sources/Timeline/View/TimelineView.swift | 13 +- 5 files changed, 262 insertions(+), 94 deletions(-) create mode 100644 Packages/Timeline/Sources/Timeline/View/TimelineQuickAccessPills.swift diff --git a/IceCubesApp/App/AppRegistry.swift b/IceCubesApp/App/AppRegistry.swift index 6d9788bc..ee73f33e 100644 --- a/IceCubesApp/App/AppRegistry.swift +++ b/IceCubesApp/App/AppRegistry.swift @@ -33,11 +33,13 @@ extension View { ConversationDetailView(conversation: conversation) case let .hashTag(tag, accountId): TimelineView(timeline: .constant(.hashtag(tag: tag, accountId: accountId)), + pinnedFilters: .constant([]), selectedTagGroup: .constant(nil), scrollToTopSignal: .constant(0), canFilterTimeline: false) case let .list(list): TimelineView(timeline: .constant(.list(list: list)), + pinnedFilters: .constant([]), selectedTagGroup: .constant(nil), scrollToTopSignal: .constant(0), canFilterTimeline: false) @@ -53,6 +55,7 @@ extension View { AccountsListView(mode: .accountsList(accounts: accounts)) case .trendingTimeline: TimelineView(timeline: .constant(.trending), + pinnedFilters: .constant([]), selectedTagGroup: .constant(nil), scrollToTopSignal: .constant(0), canFilterTimeline: false) diff --git a/IceCubesApp/App/Tabs/Timeline/TimelineTab.swift b/IceCubesApp/App/Tabs/Timeline/TimelineTab.swift index 02ed9b0d..d40a617d 100644 --- a/IceCubesApp/App/Tabs/Timeline/TimelineTab.swift +++ b/IceCubesApp/App/Tabs/Timeline/TimelineTab.swift @@ -29,6 +29,7 @@ struct TimelineTab: View { @Query(sort: \TagGroup.creationDate, order: .reverse) var tagGroups: [TagGroup] @AppStorage("last_timeline_filter") var lastTimelineFilter: TimelineFilter = .home + @AppStorage("timeline_pinned_filters") private var pinnedFilters: [TimelineFilter] = [] private let canFilterTimeline: Bool @@ -41,6 +42,7 @@ struct TimelineTab: View { var body: some View { NavigationStack(path: $routerPath.path) { TimelineView(timeline: $timeline, + pinnedFilters: $pinnedFilters, selectedTagGroup: $selectedTagGroup, scrollToTopSignal: $scrollToTopSignal, canFilterTimeline: canFilterTimeline) @@ -119,96 +121,13 @@ struct TimelineTab: View { @ViewBuilder private var timelineFilterButton: some View { - if timeline.supportNewestPagination { - Button { - timeline = .latest - } label: { - Label(TimelineFilter.latest.localizedTitle(), systemImage: TimelineFilter.latest.iconName() ?? "") - } - if timeline == .home { - Button { - timeline = .resume - } label: { - VStack { - Label(TimelineFilter.resume.localizedTitle(), - systemImage: TimelineFilter.resume.iconName() ?? "") - } - } - } - Divider() - } - ForEach(TimelineFilter.availableTimeline(client: client), id: \.self) { timeline in - Button { - self.timeline = timeline - } label: { - Label(timeline.localizedTitle(), systemImage: timeline.iconName() ?? "") - } - } - if !currentAccount.lists.isEmpty { - Menu("timeline.filter.lists") { - ForEach(currentAccount.sortedLists) { list in - Button { - timeline = .list(list: list) - } label: { - Label(list.title, systemImage: "list.bullet") - } - } - Button { - routerPath.presentedSheet = .listCreate - } label: { - Label("account.list.create", systemImage: "plus") - } - } - } - - if !currentAccount.tags.isEmpty { - Menu("timeline.filter.tags") { - ForEach(currentAccount.sortedTags) { tag in - Button { - timeline = .hashtag(tag: tag.name, accountId: nil) - } label: { - Label("#\(tag.name)", systemImage: "number") - } - } - } - } - - Menu("timeline.filter.local") { - ForEach(localTimelines) { remoteLocal in - Button { - timeline = .remoteLocal(server: remoteLocal.instance, filter: .local) - } label: { - VStack { - Label(remoteLocal.instance, systemImage: "dot.radiowaves.right") - } - } - } - Button { - routerPath.presentedSheet = .addRemoteLocalTimeline - } label: { - Label("timeline.filter.add-local", systemImage: "badge.plus.radiowaves.right") - } - } - - Menu("timeline.filter.tag-groups") { - ForEach(tagGroups) { group in - Button { - selectedTagGroup = group - timeline = .tagGroup(title: group.title, tags: group.tags) - } label: { - VStack { - let icon = group.symbolName.isEmpty ? "number" : group.symbolName - Label(group.title, systemImage: icon) - } - } - } - - Button { - routerPath.presentedSheet = .addTagGroup - } label: { - Label("timeline.filter.add-tag-groups", systemImage: "plus") - } - } + latestOrResumeButtons + pinMenuButton + timelineFiltersButtons + listsFiltersButons + tagsFiltersButtons + localTimelinesFiltersButtons + tagGroupsFiltersButtons } private var addAccountButton: some View { @@ -263,6 +182,136 @@ struct TimelineTab: View { } } } + + @ViewBuilder + private var latestOrResumeButtons: some View { + if timeline.supportNewestPagination { + Button { + timeline = .latest + } label: { + Label(TimelineFilter.latest.localizedTitle(), systemImage: TimelineFilter.latest.iconName() ?? "") + } + if timeline == .home { + Button { + timeline = .resume + } label: { + VStack { + Label(TimelineFilter.resume.localizedTitle(), + systemImage: TimelineFilter.resume.iconName() ?? "") + } + } + } + Divider() + } + } + + @ViewBuilder + private var pinMenuButton: some View { + let index = pinnedFilters.firstIndex(where: { $0.id == timeline.id}) + Button { + withAnimation { + if let index { + pinnedFilters.remove(at: index) + } else { + pinnedFilters.append(timeline) + } + } + } label: { + if index != nil { + Label("status.action.unpin", systemImage: "pin.slash") + } else { + Label("status.action.pin", systemImage: "pin") + } + } + + Divider() + } + + private var timelineFiltersButtons: some View { + ForEach(TimelineFilter.availableTimeline(client: client), id: \.self) { timeline in + Button { + self.timeline = timeline + } label: { + Label(timeline.localizedTitle(), systemImage: timeline.iconName() ?? "") + } + } + } + + @ViewBuilder + private var listsFiltersButons: some View { + if !currentAccount.lists.isEmpty { + Menu("timeline.filter.lists") { + ForEach(currentAccount.sortedLists) { list in + Button { + timeline = .list(list: list) + } label: { + Label(list.title, systemImage: "list.bullet") + } + } + Button { + routerPath.presentedSheet = .listCreate + } label: { + Label("account.list.create", systemImage: "plus") + } + } + } + } + + @ViewBuilder + private var tagsFiltersButtons: some View { + if !currentAccount.tags.isEmpty { + Menu("timeline.filter.tags") { + ForEach(currentAccount.sortedTags) { tag in + Button { + timeline = .hashtag(tag: tag.name, accountId: nil) + } label: { + Label("#\(tag.name)", systemImage: "number") + } + } + } + } + } + + private var localTimelinesFiltersButtons: some View { + Menu("timeline.filter.local") { + ForEach(localTimelines) { remoteLocal in + Button { + timeline = .remoteLocal(server: remoteLocal.instance, filter: .local) + } label: { + VStack { + Label(remoteLocal.instance, systemImage: "dot.radiowaves.right") + } + } + } + Button { + routerPath.presentedSheet = .addRemoteLocalTimeline + } label: { + Label("timeline.filter.add-local", systemImage: "badge.plus.radiowaves.right") + } + } + } + + private var tagGroupsFiltersButtons: some View { + Menu("timeline.filter.tag-groups") { + ForEach(tagGroups) { group in + Button { + selectedTagGroup = group + timeline = .tagGroup(title: group.title, tags: group.tags) + } label: { + VStack { + let icon = group.symbolName.isEmpty ? "number" : group.symbolName + Label(group.title, systemImage: icon) + } + } + } + + Button { + routerPath.presentedSheet = .addTagGroup + } label: { + Label("timeline.filter.add-tag-groups", systemImage: "plus") + } + } + } private func resetTimelineFilter() { if client.isAuth, canFilterTimeline { diff --git a/Packages/Timeline/Sources/Timeline/TimelineFilter.swift b/Packages/Timeline/Sources/Timeline/TimelineFilter.swift index e89067fb..0bae2b02 100644 --- a/Packages/Timeline/Sources/Timeline/TimelineFilter.swift +++ b/Packages/Timeline/Sources/Timeline/TimelineFilter.swift @@ -29,7 +29,8 @@ public enum RemoteTimelineFilter: String, CaseIterable, Hashable, Equatable { } } -public enum TimelineFilter: Hashable, Equatable { +public enum TimelineFilter: Hashable, Equatable, Identifiable { + case home, local, federated, trending case hashtag(tag: String, accountId: String?) case tagGroup(title: String, tags: [String]) @@ -38,6 +39,10 @@ public enum TimelineFilter: Hashable, Equatable { case latest case resume + public var id: String { + title + } + public func hash(into hasher: inout Hasher) { hasher.combine(title) } @@ -204,7 +209,7 @@ extension TimelineFilter: Codable { accountId: accountId ) case .tagGroup: - var nestedContainer = try container.nestedUnkeyedContainer(forKey: .hashtag) + var nestedContainer = try container.nestedUnkeyedContainer(forKey: .tagGroup) let title = try nestedContainer.decode(String.self) let tags = try nestedContainer.decode([String].self) self = .tagGroup( @@ -259,7 +264,7 @@ extension TimelineFilter: Codable { case let .list(list): try container.encode(list, forKey: .list) case let .remoteLocal(server, filter): - var nestedContainer = container.nestedUnkeyedContainer(forKey: .hashtag) + var nestedContainer = container.nestedUnkeyedContainer(forKey: .remoteLocal) try nestedContainer.encode(server) try nestedContainer.encode(filter) case .latest: diff --git a/Packages/Timeline/Sources/Timeline/View/TimelineQuickAccessPills.swift b/Packages/Timeline/Sources/Timeline/View/TimelineQuickAccessPills.swift new file mode 100644 index 00000000..20a73206 --- /dev/null +++ b/Packages/Timeline/Sources/Timeline/View/TimelineQuickAccessPills.swift @@ -0,0 +1,100 @@ +import SwiftUI +import Env +import Models +import DesignSystem + +@MainActor +struct TimelineQuickAccessPills: View { + @Environment(Theme.self) private var theme + @Environment(CurrentAccount.self) private var currentAccount + + @Binding var pinnedFilters: [TimelineFilter] + @Binding var timeline: TimelineFilter + + @State private var draggedFilter: TimelineFilter? + + var body: some View { + ScrollView(.horizontal) { + HStack { + ForEach(pinnedFilters) { filter in + makePill(filter) + } + } + } + .scrollClipDisabled() + .scrollIndicators(.never) + .listRowInsets(EdgeInsets(top: 8, leading: .layoutPadding, bottom: 8, trailing: .layoutPadding)) +#if !os(visionOS) + .listRowBackground(theme.primaryBackgroundColor) +#endif + .listRowSeparator(.hidden) + } + + @ViewBuilder + private func makePill(_ filter: TimelineFilter) -> some View { + if !isFilterSupport(filter) { + EmptyView() + } else if filter == timeline { + makeButton(filter) + .buttonStyle(.borderedProminent) + } else { + makeButton(filter) + .buttonStyle(.bordered) + } + } + + private func makeButton(_ filter: TimelineFilter) -> some View { + Button { + timeline = filter + } label: { + Label(filter.localizedTitle(), systemImage: filter.iconName() ?? "") + .font(.callout) + } + .transition(.push(from: .leading).combined(with: .opacity)) + .onDrag { + draggedFilter = filter + return NSItemProvider() + } + .onDrop(of: [.text], delegate: PillDropDelegate(destinationItem: filter, + items: $pinnedFilters, + draggedItem: $draggedFilter)) + } + + private func isFilterSupport(_ filter: TimelineFilter) -> Bool { + switch filter { + case .list(let list): + return currentAccount.lists.contains(where: { $0.id == list.id }) + default: + return true + } + } +} + +struct PillDropDelegate: DropDelegate { + let destinationItem: TimelineFilter + @Binding var items: [TimelineFilter] + @Binding var draggedItem: TimelineFilter? + + func dropUpdated(info: DropInfo) -> DropProposal? { + return DropProposal(operation: .move) + } + + func performDrop(info: DropInfo) -> Bool { + draggedItem = nil + return true + } + + func dropEntered(info: DropInfo) { + if let draggedItem { + let fromIndex = items.firstIndex(of: draggedItem) + if let fromIndex { + let toIndex = items.firstIndex(of: destinationItem) + if let toIndex, fromIndex != toIndex { + withAnimation { + self.items.move(fromOffsets: IndexSet(integer: fromIndex), toOffset: (toIndex > fromIndex ? (toIndex + 1) : toIndex)) + } + } + } + } + } +} diff --git a/Packages/Timeline/Sources/Timeline/View/TimelineView.swift b/Packages/Timeline/Sources/Timeline/View/TimelineView.swift index 2b5daa0c..edbe4fd5 100644 --- a/Packages/Timeline/Sources/Timeline/View/TimelineView.swift +++ b/Packages/Timeline/Sources/Timeline/View/TimelineView.swift @@ -25,6 +25,7 @@ public struct TimelineView: View { @State private var collectionView: UICollectionView? @Binding var timeline: TimelineFilter + @Binding var pinnedFilters: [TimelineFilter] @Binding var selectedTagGroup: TagGroup? @Binding var scrollToTopSignal: Int @@ -33,11 +34,13 @@ public struct TimelineView: View { private let canFilterTimeline: Bool public init(timeline: Binding, + pinnedFilters: Binding<[TimelineFilter]>, selectedTagGroup: Binding, scrollToTopSignal: Binding, canFilterTimeline: Bool) { _timeline = timeline + _pinnedFilters = pinnedFilters _selectedTagGroup = selectedTagGroup _scrollToTopSignal = scrollToTopSignal self.canFilterTimeline = canFilterTimeline @@ -48,7 +51,7 @@ public struct TimelineView: View { ZStack(alignment: .top) { List { scrollToTopView - TimelineTagGroupheaderView(group: $selectedTagGroup, timeline: $viewModel.timeline) + TimelineTagGroupheaderView(group: $selectedTagGroup, timeline: $timeline) TimelineTagHeaderView(tag: $viewModel.tag) switch viewModel.timeline { case .remoteLocal: @@ -77,6 +80,14 @@ public struct TimelineView: View { PendingStatusesObserverView(observer: viewModel.pendingStatusesObserver) } } + .safeAreaInset(edge: .top) { + if canFilterTimeline, !pinnedFilters.isEmpty { + TimelineQuickAccessPills(pinnedFilters: $pinnedFilters, timeline: $timeline) + .padding(.vertical, 8) + .padding(.horizontal, .layoutPadding) + .background(theme.primaryBackgroundColor.opacity(0.50).background(Material.ultraThin)) + } + } .onChange(of: viewModel.scrollToIndex) { _, newValue in if let collectionView, let newValue,