mirror of
https://github.com/Dimillian/IceCubesApp.git
synced 2024-12-15 19:56:36 +00:00
Timeline: Add pills quick access
This commit is contained in:
parent
631707a798
commit
fe66acbd39
5 changed files with 262 additions and 94 deletions
|
@ -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)
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<TimelineFilter>,
|
||||
pinnedFilters: Binding<[TimelineFilter]>,
|
||||
selectedTagGroup: Binding<TagGroup?>,
|
||||
scrollToTopSignal: Binding<Int>,
|
||||
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,
|
||||
|
|
Loading…
Reference in a new issue