mirror of
https://github.com/kean/Pulse.git
synced 2026-05-30 21:07:33 +00:00
422 lines
14 KiB
Swift
422 lines
14 KiB
Swift
// The MIT License (MIT)
|
|
//
|
|
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
|
|
|
|
#if os(iOS) || os(macOS) || os(visionOS)
|
|
|
|
import SwiftUI
|
|
import Pulse
|
|
import CoreData
|
|
import Combine
|
|
|
|
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
|
struct ConsoleToolbarView: View {
|
|
@EnvironmentObject private var environment: ConsoleEnvironment
|
|
@EnvironmentObject private var filters: ConsoleFiltersViewModel
|
|
@EnvironmentObject private var searchViewModel: ConsoleSearchViewModel
|
|
@Environment(\.isSearching) private var isSearching
|
|
|
|
var body: some View {
|
|
HStack(spacing: 0) {
|
|
HStack(spacing: 9) {
|
|
ConsoleSessionsPill()
|
|
ConsoleFilterPill(isSearching: isSearching)
|
|
}
|
|
Spacer(minLength: 9)
|
|
HStack(spacing: 9) {
|
|
ConsoleSortByPill()
|
|
ConsoleGroupByPill()
|
|
ConsoleOnlyErrorsButton(isEnabled: $filters.options.isOnlyErrors)
|
|
if isSearching {
|
|
ConsoleSearchContextMenu(viewModel: searchViewModel)
|
|
.transition(.scale(scale: 0.3, anchor: .trailing).combined(with: .opacity))
|
|
}
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.dynamicTypeSize(...DynamicTypeSize.accessibility2)
|
|
.animation(.snappy, value: filters.options.isOnlyErrors)
|
|
.animation(.snappy, value: filters.options.filters)
|
|
.animation(.snappy, value: environment.listOptions)
|
|
.animation(.snappy, value: searchViewModel.options)
|
|
.animation(.snappy, value: searchViewModel.scopes)
|
|
.animation(.snappy, value: isSearching)
|
|
}
|
|
}
|
|
|
|
private let consolePillResetXmarkWidth: CGFloat = 24
|
|
|
|
@available(iOS 18, tvOS 18, macOS 15, visionOS 1, *)
|
|
private struct ConsolePillResetXmark: View {
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
Image(systemName: "xmark.circle.fill")
|
|
.font(.subheadline)
|
|
.foregroundStyle(Color.white.opacity(0.85))
|
|
.padding(.leading, 2)
|
|
.padding(.trailing, 8)
|
|
.padding(.vertical, 8)
|
|
.contentShape(Rectangle())
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
/// Wraps a Button-style trigger in the standard console pill background,
|
|
/// optionally appending a reset xmark button when `isActive` and `resetAction` is set.
|
|
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
|
private struct ConsoleTogglePill<Trigger: View>: View {
|
|
let isActive: Bool
|
|
var activeColor: Color = .accentColor
|
|
let resetAction: (() -> Void)?
|
|
@ViewBuilder let trigger: () -> Trigger
|
|
|
|
private var hasReset: Bool { isActive && resetAction != nil }
|
|
|
|
var body: some View {
|
|
HStack(spacing: 0) {
|
|
trigger()
|
|
.padding(.leading, 9)
|
|
.padding(.vertical, 8)
|
|
.padding(.trailing, hasReset ? 4 : 8)
|
|
if let resetAction, isActive {
|
|
ConsolePillResetXmark(action: resetAction)
|
|
}
|
|
}
|
|
.frame(minWidth: 34) // Never narrower than tall — keep at least square
|
|
.toolbarPillBackground(isActive: isActive, activeColor: activeColor)
|
|
.animation(.snappy, value: isActive)
|
|
}
|
|
}
|
|
|
|
/// A pill whose entire surface is the label of a Menu, so tapping anywhere on the
|
|
/// pill opens the menu. When `resetAction` is non-nil and `isActive`, an xmark
|
|
/// reset button is overlaid on the trailing edge with its own tap area.
|
|
@available(iOS 18, tvOS 18, macOS 15, visionOS 1, *)
|
|
private struct ConsoleMenuPill<MenuContent: View>: View {
|
|
let systemImage: String
|
|
var title: String? = nil
|
|
let isActive: Bool
|
|
let resetAction: (() -> Void)?
|
|
@ViewBuilder let menuContent: () -> MenuContent
|
|
|
|
private var hasReset: Bool { isActive && resetAction != nil }
|
|
|
|
var body: some View {
|
|
Menu {
|
|
menuContent()
|
|
} label: {
|
|
HStack(spacing: 0) {
|
|
ToolbarPillLabel(title: title, systemImage: systemImage, isActive: isActive)
|
|
.padding(.leading, 9)
|
|
.padding(.vertical, 8)
|
|
.padding(.trailing, hasReset ? 4 : 8)
|
|
if hasReset {
|
|
// Reserve space for the overlayed xmark button so the menu
|
|
// label visually leaves room for it.
|
|
Color.clear
|
|
.frame(width: consolePillResetXmarkWidth, height: 1)
|
|
}
|
|
}
|
|
.frame(minWidth: 34) // Never narrower than tall — keep at least square
|
|
.toolbarPillBackground(isActive: isActive)
|
|
.animation(.snappy, value: isActive)
|
|
}
|
|
.menuStyle(.button)
|
|
.buttonStyle(.plain)
|
|
.overlay(alignment: .trailing) {
|
|
if let resetAction, isActive {
|
|
ConsolePillResetXmark(action: resetAction)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
|
private struct ConsoleFilterPill: View {
|
|
let isSearching: Bool
|
|
|
|
@EnvironmentObject private var environment: ConsoleEnvironment
|
|
@EnvironmentObject private var filters: ConsoleFiltersViewModel
|
|
|
|
private var isActive: Bool {
|
|
!filters.isDefaultFilters(for: filters.mode)
|
|
}
|
|
|
|
var body: some View {
|
|
ConsoleTogglePill(isActive: isActive, resetAction: resetFilters) {
|
|
Button(action: openFilters) {
|
|
ToolbarPillLabel(
|
|
title: "Filters",
|
|
systemImage: "line.3.horizontal.decrease",
|
|
isActive: isActive
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
private func openFilters() {
|
|
environment.router.isShowingFilters = true
|
|
}
|
|
|
|
private func resetFilters() {
|
|
filters.resetAll()
|
|
}
|
|
}
|
|
|
|
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
|
package struct ConsoleOnlyErrorsButton: View {
|
|
@Binding package var isEnabled: Bool
|
|
|
|
package init(isEnabled: Binding<Bool>) {
|
|
self._isEnabled = isEnabled
|
|
}
|
|
|
|
package var body: some View {
|
|
ConsoleTogglePill(isActive: isEnabled, activeColor: .red, resetAction: nil) {
|
|
Button(action: { isEnabled.toggle() }) {
|
|
ToolbarPillLabel(
|
|
systemImage: isEnabled ? "exclamationmark.octagon.fill" : "exclamationmark.octagon",
|
|
isActive: isEnabled
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.dynamicTypeSize(...DynamicTypeSize.accessibility1)
|
|
}
|
|
}
|
|
|
|
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
|
private struct ConsoleSessionsPill: View {
|
|
@EnvironmentObject private var environment: ConsoleEnvironment
|
|
@EnvironmentObject private var filters: ConsoleFiltersViewModel
|
|
|
|
private var isActive: Bool {
|
|
let selected = filters.options.sessions
|
|
if let sessionID = environment.store.currentSessionID {
|
|
return selected != [sessionID]
|
|
}
|
|
return !selected.isEmpty
|
|
}
|
|
|
|
var body: some View {
|
|
ConsoleTogglePill(isActive: isActive, resetAction: nil) {
|
|
Button(action: { environment.router.isShowingSessions = true }) {
|
|
ToolbarPillLabel(
|
|
systemImage: "list.bullet.clipboard",
|
|
isActive: isActive
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
}
|
|
|
|
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
|
private struct ConsoleSortByPill: View {
|
|
@EnvironmentObject private var environment: ConsoleEnvironment
|
|
|
|
private var isActive: Bool {
|
|
if environment.mode == .network {
|
|
return environment.listOptions.taskSortBy != .dateCreated
|
|
|| environment.listOptions.order != .descending
|
|
} else {
|
|
return environment.listOptions.messageSortBy != .dateCreated
|
|
|| environment.listOptions.order != .descending
|
|
}
|
|
}
|
|
|
|
private var fieldLabel: String {
|
|
environment.mode == .network
|
|
? environment.listOptions.taskSortBy.pillTitle
|
|
: environment.listOptions.messageSortBy.pillTitle
|
|
}
|
|
|
|
var body: some View {
|
|
ConsoleMenuPill(
|
|
systemImage: environment.listOptions.order == .ascending ? "arrow.up" : "arrow.down",
|
|
title: fieldLabel,
|
|
isActive: isActive,
|
|
resetAction: reset
|
|
) {
|
|
ConsoleSortByMenuContent()
|
|
}
|
|
}
|
|
|
|
private func reset() {
|
|
environment.listOptions.order = .descending
|
|
if environment.mode == .network {
|
|
environment.listOptions.taskSortBy = .dateCreated
|
|
} else {
|
|
environment.listOptions.messageSortBy = .dateCreated
|
|
}
|
|
}
|
|
}
|
|
|
|
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
|
struct ConsoleSortByMenuContent: View {
|
|
@EnvironmentObject private var environment: ConsoleEnvironment
|
|
|
|
var body: some View {
|
|
if environment.mode == .network {
|
|
Picker("Sort By", selection: $environment.listOptions.taskSortBy) {
|
|
ForEach(ConsoleListOptions.TaskSortBy.allCases, id: \.self) {
|
|
Text($0.rawValue).tag($0)
|
|
}
|
|
}
|
|
} else {
|
|
Picker("Sort By", selection: $environment.listOptions.messageSortBy) {
|
|
ForEach(ConsoleListOptions.MessageSortBy.allCases, id: \.self) {
|
|
Text($0.rawValue).tag($0)
|
|
}
|
|
}
|
|
}
|
|
Picker("Ordering", selection: $environment.listOptions.order) {
|
|
Text("Descending").tag(ConsoleListOptions.Ordering.descending)
|
|
Text("Ascending").tag(ConsoleListOptions.Ordering.ascending)
|
|
}
|
|
}
|
|
}
|
|
|
|
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
|
package struct ConsoleSearchContextMenu<ViewModel: ConsoleSearchOptionsHost>: View {
|
|
@ObservedObject var viewModel: ViewModel
|
|
@State private var isPresented = false
|
|
|
|
private var isOptionsActive: Bool {
|
|
viewModel.options != .default
|
|
}
|
|
|
|
private var isScopesActive: Bool {
|
|
viewModel.scopes != viewModel.savedDefaultScopes
|
|
}
|
|
|
|
private var isActive: Bool {
|
|
isOptionsActive || isScopesActive
|
|
}
|
|
|
|
package init(viewModel: ViewModel) {
|
|
self.viewModel = viewModel
|
|
}
|
|
|
|
package var body: some View {
|
|
ConsoleTogglePill(isActive: isActive, resetAction: isActive ? reset : nil) {
|
|
Button {
|
|
isPresented = true
|
|
} label: {
|
|
ToolbarPillLabel(
|
|
systemImage: "gearshape",
|
|
isActive: isActive
|
|
)
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
#if os(iOS) || os(visionOS)
|
|
.sheet(isPresented: $isPresented) {
|
|
ConsoleSearchOptionsSheet(viewModel: viewModel)
|
|
.presentationDetents([.medium, .large])
|
|
}
|
|
#endif
|
|
}
|
|
|
|
private func reset() {
|
|
viewModel.options = .default
|
|
viewModel.resetScopesToDefault()
|
|
}
|
|
}
|
|
|
|
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
|
private struct ConsoleGroupByPill: View {
|
|
@EnvironmentObject private var environment: ConsoleEnvironment
|
|
@EnvironmentObject private var listViewModel: ConsoleListViewModel
|
|
|
|
private var isActive: Bool {
|
|
if environment.mode == .network {
|
|
return environment.listOptions.taskGroupBy != .noGrouping
|
|
} else {
|
|
return environment.listOptions.messageGroupBy != .noGrouping
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
if isActive {
|
|
ConsoleMenuPill(
|
|
systemImage: "rectangle.3.group",
|
|
isActive: true,
|
|
resetAction: nil
|
|
) {
|
|
Section {
|
|
Button(action: listViewModel.collapseAllSections) {
|
|
Label("Collapse All", systemImage: "rectangle.compress.vertical")
|
|
}
|
|
Button(action: listViewModel.expandAllSections) {
|
|
Label("Expand All", systemImage: "rectangle.expand.vertical")
|
|
}
|
|
}
|
|
Section {
|
|
ConsoleRemoveGroupingButton()
|
|
}
|
|
Section {
|
|
ConsoleGroupByMenuContent()
|
|
}
|
|
}
|
|
.transition(.scale.combined(with: .opacity))
|
|
}
|
|
}
|
|
}
|
|
|
|
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
|
struct ConsoleGroupByMenuContent: View {
|
|
@EnvironmentObject private var environment: ConsoleEnvironment
|
|
|
|
var body: some View {
|
|
if environment.mode == .network {
|
|
Picker("Group By", selection: $environment.listOptions.taskGroupBy) {
|
|
ForEach(ConsoleListOptions.TaskGroupBy.allCases.filter { $0 != .noGrouping }, id: \.self) {
|
|
Text($0.rawValue).tag($0)
|
|
}
|
|
}
|
|
} else {
|
|
Picker("Group By", selection: $environment.listOptions.messageGroupBy) {
|
|
ForEach(ConsoleListOptions.MessageGroupBy.allCases.filter { $0 != .noGrouping }, id: \.self) {
|
|
Text($0.rawValue).tag($0)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
|
|
struct ConsoleRemoveGroupingButton: View {
|
|
@EnvironmentObject private var environment: ConsoleEnvironment
|
|
|
|
private var isActive: Bool {
|
|
if environment.mode == .network {
|
|
return environment.listOptions.taskGroupBy != .noGrouping
|
|
} else {
|
|
return environment.listOptions.messageGroupBy != .noGrouping
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
if isActive {
|
|
Button(role: .destructive, action: remove) {
|
|
Label("Remove Grouping", systemImage: "list.bullet")
|
|
}
|
|
}
|
|
}
|
|
|
|
private func remove() {
|
|
if environment.mode == .network {
|
|
environment.listOptions.taskGroupBy = .noGrouping
|
|
} else {
|
|
environment.listOptions.messageGroupBy = .noGrouping
|
|
}
|
|
}
|
|
}
|
|
|
|
#endif
|