From 7fd39d259b1cf0dcd0885b92c4c82ab97822bcd8 Mon Sep 17 00:00:00 2001 From: Alex Grebenyuk Date: Sat, 15 Apr 2023 20:39:14 -0400 Subject: [PATCH] Move SessionsView to a separate file --- Pulse.xcodeproj/project.pbxproj | 20 +- .../Console/Views/ConsoleInspectorsView.swift | 2 +- .../Console/Views/ConsoleRouterView.swift | 2 +- .../Console/Views/ConsoleSessionsView.swift | 297 ------------------ .../Sessions/ConsoleSessionListView.swift | 155 +++++++++ .../Features/Sessions/SessionListView.swift | 155 +++++++++ .../Features/Sessions/SessionsView.swift | 155 +++++++++ Sources/PulseUI/Views/ShareStoreView.swift | 2 +- 8 files changed, 484 insertions(+), 304 deletions(-) delete mode 100644 Sources/PulseUI/Features/Console/Views/ConsoleSessionsView.swift create mode 100644 Sources/PulseUI/Features/Sessions/ConsoleSessionListView.swift create mode 100644 Sources/PulseUI/Features/Sessions/SessionListView.swift create mode 100644 Sources/PulseUI/Features/Sessions/SessionsView.swift diff --git a/Pulse.xcodeproj/project.pbxproj b/Pulse.xcodeproj/project.pbxproj index e15403a7..e1a25f5d 100644 --- a/Pulse.xcodeproj/project.pbxproj +++ b/Pulse.xcodeproj/project.pbxproj @@ -80,7 +80,7 @@ 0C7A0E00297C51CE00B4B69D /* ConsoleListOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7A0DFF297C51CE00B4B69D /* ConsoleListOptions.swift */; }; 0C7A0E02297CE71400B4B69D /* FormattersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C7A0E01297CE71400B4B69D /* FormattersTests.swift */; }; 0C840D4A29E07BE900362214 /* SelectableButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C840D4929E07BE900362214 /* SelectableButton.swift */; }; - 0C840D4C29E0A09B00362214 /* ConsoleSessionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C840D4B29E0A09B00362214 /* ConsoleSessionsView.swift */; }; + 0C840D4C29E0A09B00362214 /* SessionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C840D4B29E0A09B00362214 /* SessionsView.swift */; }; 0C94239429B4D73E007F2784 /* ConsoleSearchOccurrenceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C94239329B4D73E007F2784 /* ConsoleSearchOccurrenceTests.swift */; }; 0C9F04F92884F34A0035239F /* Pulse_Demo_macOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C9F04F82884F34A0035239F /* Pulse_Demo_macOSApp.swift */; }; 0C9F04FD2884F34A0035239F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0C9F04FC2884F34A0035239F /* Assets.xcassets */; }; @@ -276,6 +276,7 @@ 0CF5E5FA2997DED7009FF7AE /* ConsoleTableViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF5E5F82997DE86009FF7AE /* ConsoleTableViewModelTests.swift */; }; 0CF5E60929981ADF009FF7AE /* ShareStoreViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CF5E60729981ADC009FF7AE /* ShareStoreViewModel.swift */; }; 0CFD1CDB29E2E8FD003872EF /* LoggerStore+Remote.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CFD1CDA29E2E8FD003872EF /* LoggerStore+Remote.swift */; }; + 0CFF79DD29EB7A8E00BE767B /* SessionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CFF79DB29EB7A8B00BE767B /* SessionListView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -532,7 +533,7 @@ 0C7A0DFF297C51CE00B4B69D /* ConsoleListOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsoleListOptions.swift; sourceTree = ""; }; 0C7A0E01297CE71400B4B69D /* FormattersTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FormattersTests.swift; sourceTree = ""; }; 0C840D4929E07BE900362214 /* SelectableButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectableButton.swift; sourceTree = ""; }; - 0C840D4B29E0A09B00362214 /* ConsoleSessionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsoleSessionsView.swift; sourceTree = ""; }; + 0C840D4B29E0A09B00362214 /* SessionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionsView.swift; sourceTree = ""; }; 0C94239329B4D73E007F2784 /* ConsoleSearchOccurrenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsoleSearchOccurrenceTests.swift; sourceTree = ""; }; 0C9F04F62884F34A0035239F /* Pulse Demo macOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Pulse Demo macOS.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 0C9F04F82884F34A0035239F /* Pulse_Demo_macOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Pulse_Demo_macOSApp.swift; sourceTree = ""; }; @@ -728,6 +729,7 @@ 0CF5E5F82997DE86009FF7AE /* ConsoleTableViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ConsoleTableViewModelTests.swift; sourceTree = ""; }; 0CF5E60729981ADC009FF7AE /* ShareStoreViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShareStoreViewModel.swift; sourceTree = ""; }; 0CFD1CDA29E2E8FD003872EF /* LoggerStore+Remote.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "LoggerStore+Remote.swift"; sourceTree = ""; }; + 0CFF79DB29EB7A8B00BE767B /* SessionListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SessionListView.swift; sourceTree = ""; }; 49E82A8526D107A00070244F /* AlamofireIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlamofireIntegration.swift; sourceTree = ""; }; 49E82A8826D1083D0070244F /* MoyaIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoyaIntegration.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -1283,6 +1285,7 @@ 0CF0D5DD296F189500EED9D4 /* Inspector */, 0CF0D5B3296F189500EED9D4 /* Filters */, 0CF0D5DE296F189500EED9D4 /* Metrics */, + 0CFF79DA29EB7A8000BE767B /* Sessions */, 0CF0D5CE296F189500EED9D4 /* Settings */, 0CF0D5D6296F189500EED9D4 /* MessageDetails */, ); @@ -1429,7 +1432,6 @@ 0CE45E0B2977813400249C5D /* ConsoleToolbarView.swift */, 0C7A0DFC297C382300B4B69D /* ConsoleShareButton.swift */, 0CB345A5298DEEA6009428B2 /* ConsoleInspectorsView.swift */, - 0C840D4B29E0A09B00362214 /* ConsoleSessionsView.swift */, ); path = Views; sourceTree = ""; @@ -1473,6 +1475,15 @@ path = PulseUITests; sourceTree = ""; }; + 0CFF79DA29EB7A8000BE767B /* Sessions */ = { + isa = PBXGroup; + children = ( + 0C840D4B29E0A09B00362214 /* SessionsView.swift */, + 0CFF79DB29EB7A8B00BE767B /* SessionListView.swift */, + ); + path = Sessions; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -1988,6 +1999,7 @@ 0CF0D66F296F189600EED9D4 /* NetworkInspectorView-watchos.swift in Sources */, 0CF0D671296F189600EED9D4 /* NetworkInspectorResponseBodyView.swift in Sources */, 0CF0D65B296F189600EED9D4 /* SettingsView-macos.swift in Sources */, + 0CFF79DD29EB7A8E00BE767B /* SessionListView.swift in Sources */, 0CF0D626296F189600EED9D4 /* FileViewer.swift in Sources */, 0C40C09A296F77F1009ECF16 /* ConsoleSearchToggleCell.swift in Sources */, 0C63A380297A02D200F6A6A5 /* StringSearchOptions.swift in Sources */, @@ -2096,7 +2108,7 @@ 0C40C098296F777D009ECF16 /* ConsoleSearchResponseSourceCell.swift in Sources */, 0C3002B32986CEF30055F6C2 /* LoggerStoreIndex.swift in Sources */, 0CF0D689296F189600EED9D4 /* MainViewController.swift in Sources */, - 0C840D4C29E0A09B00362214 /* ConsoleSessionsView.swift in Sources */, + 0C840D4C29E0A09B00362214 /* SessionsView.swift in Sources */, 0CA32DE729D5E028001712E8 /* NSAttributedString+Extensions.swift in Sources */, 0CF0D60F296F189600EED9D4 /* UIKit+Extensions.swift in Sources */, 0CF0D610296F189600EED9D4 /* SpinnerView.swift in Sources */, diff --git a/Sources/PulseUI/Features/Console/Views/ConsoleInspectorsView.swift b/Sources/PulseUI/Features/Console/Views/ConsoleInspectorsView.swift index 56d8bffa..51b241d1 100644 --- a/Sources/PulseUI/Features/Console/Views/ConsoleInspectorsView.swift +++ b/Sources/PulseUI/Features/Console/Views/ConsoleInspectorsView.swift @@ -63,7 +63,7 @@ struct ConsoleInspectorsView: View { } case .sessions: if #available(macOS 13, *) { - ConsoleSessionsView() + SessionsView() } } } diff --git a/Sources/PulseUI/Features/Console/Views/ConsoleRouterView.swift b/Sources/PulseUI/Features/Console/Views/ConsoleRouterView.swift index b72d54b3..f325ed14 100644 --- a/Sources/PulseUI/Features/Console/Views/ConsoleRouterView.swift +++ b/Sources/PulseUI/Features/Console/Views/ConsoleRouterView.swift @@ -73,7 +73,7 @@ extension ConsoleRouterView { @ViewBuilder private var destinationSessions: some View { NavigationView { - ConsoleSessionsView() + SessionsView() .navigationTitle("Sessions") .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/Sources/PulseUI/Features/Console/Views/ConsoleSessionsView.swift b/Sources/PulseUI/Features/Console/Views/ConsoleSessionsView.swift deleted file mode 100644 index 90d6bbfa..00000000 --- a/Sources/PulseUI/Features/Console/Views/ConsoleSessionsView.swift +++ /dev/null @@ -1,297 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2020–2023 Alexander Grebenyuk (github.com/kean). - -import Foundation -import Pulse -import SwiftUI -import CoreData -import Combine - -#if os(iOS) || os(macOS) - -#warning("preselect session on macOS too") -#warning("add sharing on macOS too") -@available(macOS 13, *) -struct ConsoleSessionsView: View { - @State private var selection: Set = [] - @State private var sharedSessions: SharedSessions? - @State private var editMode: EditMode = .inactive - - @EnvironmentObject private var consoleViewModel: ConsoleViewModel - @Environment(\.store) private var store - - var body: some View { - if let version = Version(store.version), version < Version(3, 6, 0) { - PlaceholderView(imageName: "questionmark.app", title: "Unsupported", subtitle: "This feature requires a store created by Pulse version 3.6.0 or higher").padding() - } else { - content - } - } - - @ViewBuilder - private var content: some View { -#if os(macOS) - VStack { - list - HStack { - Spacer() - SearchBar(title: "Filter", imageName: "line.3.horizontal.decrease.circle", text: $filterTerm) - .frame(maxWidth: 220) - }.padding(8) - } -#else - list.toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button(editMode.isEditing ? "Done" : "Edit") { - withAnimation { - editMode = editMode.isEditing ? .inactive : .active - } - } - } - ToolbarItem(placement: .bottomBar) { - if editMode == .active { - bottomBar - } - } - } - .sheet(item: $sharedSessions) { sessions in - NavigationView { - ShareStoreView(sessions: sessions.ids, onDismiss: { sharedSessions = nil }) - }.backport.presentationDetents([.medium]) - } -#endif - } - - private var list: some View { - ConsoleSessionListView(selection: $selection, sharedSessions: $sharedSessions) -#if os(iOS) - .environment(\.editMode, $editMode) - .onChange(of: selection) { - guard !editMode.isEditing, !$0.isEmpty else { return } - showInConsole(sessions: $0) - } -#else - .onChange(of: selection) { - guard consoleViewModel.searchCriteriaViewModel.criteria.shared.sessions.selection != $0 else { return } - consoleViewModel.searchCriteriaViewModel.select(sessions: $0) - } -#endif - } - -#if os(iOS) - var bottomBar: some View { - HStack { - Button.destructive(action: { - store.removeSessions(withIDs: selection) - }, label: { Image(systemName: "trash") }) - .disabled(selection.isEmpty || selection == [store.session.id]) - - Spacer() - - // It should ideally be done using stringsdict, but Pulse - // doesn't support localization. - if selection.count % 10 == 1 { - Text("\(selection.count) Session Selected") - } else { - Text("\(selection.count) Sessions Selected") - } - - Spacer() - - Button(action: { sharedSessions = SharedSessions(ids: selection) }, label: { - Image(systemName: "square.and.arrow.up") - }) - .disabled(selection.isEmpty) - - Menu(content: { - Button("Show in Console") { - showInConsole(sessions: selection) - }.disabled(selection.isEmpty) - }, label: { - Image(systemName: "ellipsis.circle") - }) - } - } - - private func showInConsole(sessions: Set) { - consoleViewModel.searchCriteriaViewModel.select(sessions: sessions) - consoleViewModel.router.isShowingSessions = false - } -#endif - -#if os(macOS) - @ViewBuilder - private func contextMenu(for selection: Set) -> some View { - if !store.isArchive { - Button(role: .destructive, action: { - store.removeSessions(withIDs: selection) - }, label: { Text("Remove") }) - } - } -#endif -} - -private struct ConsoleSessionListView: View { - @Binding var selection: Set - @Binding var sharedSessions: SharedSessions? - - @FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \LoggerSessionEntity.createdAt, ascending: false)]) - private var sessions: FetchedResults - - @State private var filterTerm = "" - @State private var groupedSessions: [(Date, [LoggerSessionEntity])] = [] - - @Environment(\.editMode) private var editMode - @Environment(\.store) private var store - - var body: some View { - if sessions.isEmpty { - Text("No Recorded Session") - .frame(maxWidth: .infinity, maxHeight: .infinity) - .foregroundColor(.secondary) - } else { - list - .onAppear { refreshGroups() } - .onChange(of: sessions.count) { _ in refreshGroups() } - } - } - - private func refreshGroups() { - let calendar = Calendar.current - let groups = Dictionary(grouping: sessions) { - let components = calendar.dateComponents([.day, .year, .month], from: $0.createdAt) - return calendar.date(from: components) ?? $0.createdAt - } - self.groupedSessions = Array(groups.sorted(by: { $0.key > $1.key })) - } - - private var list: some View { - List(selection: $selection) { - if !filterTerm.isEmpty { - ForEach(getFilteredSessions(), id: \.id, content: makeCell) - } else { -#if os(iOS) - ForEach(groupedSessions, id: \.0) { group in - Section(header: makeHeader(for: group.0, sessions: group.1)) { - ForEach(group.1, id: \.id, content: makeCell) - } - } -#else - ForEach(sessions, id: \.id, content: makeCell) -#endif - } - } -#if os(iOS) - .listStyle(.plain) - .backport.searchable(text: $filterTerm) -#else - .listStyle(.inset) - .backport.hideListContentBackground() - .contextMenu(forSelectionType: UUID.self, menu: contextMenu) -#endif - } - - private func makeHeader(for startDate: Date, sessions: [LoggerSessionEntity]) -> some View { - HStack { - (Text(sectionTitleFormatter.string(from: startDate)) + - Text(" (\(sessions.count))").foregroundColor(.secondary.opacity(0.5))) - .font(.headline) - .padding(.vertical, 6) - - if editMode?.wrappedValue.isEditing ?? false { - Spacer() - Button("Select All") { - selection.formUnion(Set(sessions.map(\.id))) - }.font(.subheadline) - } - } - } - - private func makeCell(for session: LoggerSessionEntity) -> some View { - ConsoleSessionCell(session: session) - .backport.swipeActions { - Button(action: { - if session.id != store.session.id { - store.removeSessions(withIDs: [session.id]) - } - }, label: { - Label("Delete", systemImage: "trash") - }).backport.tint(Color.red) - - Button(action: { sharedSessions = SharedSessions(ids: [session.id]) }) { - Label("Share", systemImage: "square.and.arrow.up.fill") - }.backport.tint(.blue) - } - } - - private func getFilteredSessions() -> [LoggerSessionEntity] { - sessions.filter { $0.formattedDate(isCompact: false).localizedCaseInsensitiveContains(filterTerm) } - } -} - -struct ConsoleSessionCell: View { - let session: LoggerSessionEntity - var isCompact = true - - @Environment(\.store) private var store - @Environment(\.editMode) private var editMode - - var body: some View { - HStack(alignment: .lastTextBaseline) { - Text(session.formattedDate(isCompact: isCompact)) - .fontWeight(store.session.id == session.id ? .medium : .regular) - .lineLimit(1) - .layoutPriority(1) - Spacer() - if let version = session.fullVersion { - Text(version) - .lineLimit(1) - .frame(minWidth: 40) -#if os(macOS) - .foregroundColor(Color(UXColor.tertiaryLabelColor)) -#else - .font(.subheadline) - .foregroundColor(.secondary) -#endif - } - } - .listRowBackground((editMode?.wrappedValue.isEditing ?? false) ? Color.clear : nil) - .tag(session.id) - } -} - -private struct SharedSessions: Hashable, Identifiable { - var id: SharedSessions { self } - let ids: Set -} - -private let sectionTitleFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateStyle = .long - formatter.timeStyle = .none - formatter.doesRelativeDateFormatting = true - return formatter -}() - -#if DEBUG -@available(iOS 15.0, macOS 13, *) -struct Previews_ConsoleSessionsView_Previews: PreviewProvider { - static let viewModel = ConsoleViewModel(store: .mock) - - static var previews: some View { -#if os(iOS) - NavigationView { - ConsoleSessionsView() - .background(ConsoleRouterView(viewModel: viewModel)) - .injectingEnvironment(viewModel) - } -#else - ConsoleSessionsView() - .injectingEnvironment(viewModel) -#endif - } -} -#endif - -#endif diff --git a/Sources/PulseUI/Features/Sessions/ConsoleSessionListView.swift b/Sources/PulseUI/Features/Sessions/ConsoleSessionListView.swift new file mode 100644 index 00000000..343ec40f --- /dev/null +++ b/Sources/PulseUI/Features/Sessions/ConsoleSessionListView.swift @@ -0,0 +1,155 @@ +// The MIT License (MIT) +// +// Copyright (c) 2020–2023 Alexander Grebenyuk (github.com/kean). + +import Foundation +import Pulse +import SwiftUI +import CoreData +import Combine + +#if os(iOS) || os(macOS) + +struct SessionListView: View { + @Binding var selection: Set + @Binding var sharedSessions: SelectedSessionsIDs? + + @FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \LoggerSessionEntity.createdAt, ascending: false)]) + private var sessions: FetchedResults + + @State private var filterTerm = "" + @State private var groupedSessions: [(Date, [LoggerSessionEntity])] = [] + + @Environment(\.editMode) private var editMode + @Environment(\.store) private var store + + var body: some View { + if sessions.isEmpty { + Text("No Recorded Session") + .frame(maxWidth: .infinity, maxHeight: .infinity) + .foregroundColor(.secondary) + } else { + list + .onAppear { refreshGroups() } + .onChange(of: sessions.count) { _ in refreshGroups() } + } + } + + private func refreshGroups() { + let calendar = Calendar.current + let groups = Dictionary(grouping: sessions) { + let components = calendar.dateComponents([.day, .year, .month], from: $0.createdAt) + return calendar.date(from: components) ?? $0.createdAt + } + self.groupedSessions = Array(groups.sorted(by: { $0.key > $1.key })) + } + + private var list: some View { + List(selection: $selection) { + if !filterTerm.isEmpty { + ForEach(getFilteredSessions(), id: \.id, content: makeCell) + } else { +#if os(iOS) + ForEach(groupedSessions, id: \.0) { group in + Section(header: makeHeader(for: group.0, sessions: group.1)) { + ForEach(group.1, id: \.id, content: makeCell) + } + } +#else + ForEach(sessions, id: \.id, content: makeCell) +#endif + } + } +#if os(iOS) + .listStyle(.plain) + .backport.searchable(text: $filterTerm) +#else + .listStyle(.inset) + .backport.hideListContentBackground() + .contextMenu(forSelectionType: UUID.self, menu: contextMenu) +#endif + } + + private func makeHeader(for startDate: Date, sessions: [LoggerSessionEntity]) -> some View { + HStack { + (Text(sectionTitleFormatter.string(from: startDate)) + + Text(" (\(sessions.count))").foregroundColor(.secondary.opacity(0.5))) + .font(.headline) + .padding(.vertical, 6) + + if editMode?.wrappedValue.isEditing ?? false { + Spacer() + Button("Select All") { + selection.formUnion(Set(sessions.map(\.id))) + }.font(.subheadline) + } + } + } + + private func makeCell(for session: LoggerSessionEntity) -> some View { + ConsoleSessionCell(session: session) + .backport.swipeActions { + Button(action: { + if session.id != store.session.id { + store.removeSessions(withIDs: [session.id]) + } + }, label: { + Label("Delete", systemImage: "trash") + }).backport.tint(Color.red) + + Button(action: { sharedSessions = .init(ids: [session.id]) }) { + Label("Share", systemImage: "square.and.arrow.up.fill") + }.backport.tint(.blue) + } + } + + private func getFilteredSessions() -> [LoggerSessionEntity] { + sessions.filter { $0.formattedDate(isCompact: false).localizedCaseInsensitiveContains(filterTerm) } + } +} + +struct ConsoleSessionCell: View { + let session: LoggerSessionEntity + var isCompact = true + + @Environment(\.store) private var store + @Environment(\.editMode) private var editMode + + var body: some View { + HStack(alignment: .lastTextBaseline) { + Text(session.formattedDate(isCompact: isCompact)) + .fontWeight(store.session.id == session.id ? .medium : .regular) + .lineLimit(1) + .layoutPriority(1) + Spacer() + if let version = session.fullVersion { + Text(version) + .lineLimit(1) + .frame(minWidth: 40) +#if os(macOS) + .foregroundColor(Color(UXColor.tertiaryLabelColor)) +#else + .font(.subheadline) + .foregroundColor(.secondary) +#endif + } + } + .listRowBackground((editMode?.wrappedValue.isEditing ?? false) ? Color.clear : nil) + .tag(session.id) + } +} + +struct SelectedSessionsIDs: Hashable, Identifiable { + var id: SelectedSessionsIDs { self } + let ids: Set +} + +private let sectionTitleFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .long + formatter.timeStyle = .none + formatter.doesRelativeDateFormatting = true + return formatter +}() + +#endif diff --git a/Sources/PulseUI/Features/Sessions/SessionListView.swift b/Sources/PulseUI/Features/Sessions/SessionListView.swift new file mode 100644 index 00000000..343ec40f --- /dev/null +++ b/Sources/PulseUI/Features/Sessions/SessionListView.swift @@ -0,0 +1,155 @@ +// The MIT License (MIT) +// +// Copyright (c) 2020–2023 Alexander Grebenyuk (github.com/kean). + +import Foundation +import Pulse +import SwiftUI +import CoreData +import Combine + +#if os(iOS) || os(macOS) + +struct SessionListView: View { + @Binding var selection: Set + @Binding var sharedSessions: SelectedSessionsIDs? + + @FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \LoggerSessionEntity.createdAt, ascending: false)]) + private var sessions: FetchedResults + + @State private var filterTerm = "" + @State private var groupedSessions: [(Date, [LoggerSessionEntity])] = [] + + @Environment(\.editMode) private var editMode + @Environment(\.store) private var store + + var body: some View { + if sessions.isEmpty { + Text("No Recorded Session") + .frame(maxWidth: .infinity, maxHeight: .infinity) + .foregroundColor(.secondary) + } else { + list + .onAppear { refreshGroups() } + .onChange(of: sessions.count) { _ in refreshGroups() } + } + } + + private func refreshGroups() { + let calendar = Calendar.current + let groups = Dictionary(grouping: sessions) { + let components = calendar.dateComponents([.day, .year, .month], from: $0.createdAt) + return calendar.date(from: components) ?? $0.createdAt + } + self.groupedSessions = Array(groups.sorted(by: { $0.key > $1.key })) + } + + private var list: some View { + List(selection: $selection) { + if !filterTerm.isEmpty { + ForEach(getFilteredSessions(), id: \.id, content: makeCell) + } else { +#if os(iOS) + ForEach(groupedSessions, id: \.0) { group in + Section(header: makeHeader(for: group.0, sessions: group.1)) { + ForEach(group.1, id: \.id, content: makeCell) + } + } +#else + ForEach(sessions, id: \.id, content: makeCell) +#endif + } + } +#if os(iOS) + .listStyle(.plain) + .backport.searchable(text: $filterTerm) +#else + .listStyle(.inset) + .backport.hideListContentBackground() + .contextMenu(forSelectionType: UUID.self, menu: contextMenu) +#endif + } + + private func makeHeader(for startDate: Date, sessions: [LoggerSessionEntity]) -> some View { + HStack { + (Text(sectionTitleFormatter.string(from: startDate)) + + Text(" (\(sessions.count))").foregroundColor(.secondary.opacity(0.5))) + .font(.headline) + .padding(.vertical, 6) + + if editMode?.wrappedValue.isEditing ?? false { + Spacer() + Button("Select All") { + selection.formUnion(Set(sessions.map(\.id))) + }.font(.subheadline) + } + } + } + + private func makeCell(for session: LoggerSessionEntity) -> some View { + ConsoleSessionCell(session: session) + .backport.swipeActions { + Button(action: { + if session.id != store.session.id { + store.removeSessions(withIDs: [session.id]) + } + }, label: { + Label("Delete", systemImage: "trash") + }).backport.tint(Color.red) + + Button(action: { sharedSessions = .init(ids: [session.id]) }) { + Label("Share", systemImage: "square.and.arrow.up.fill") + }.backport.tint(.blue) + } + } + + private func getFilteredSessions() -> [LoggerSessionEntity] { + sessions.filter { $0.formattedDate(isCompact: false).localizedCaseInsensitiveContains(filterTerm) } + } +} + +struct ConsoleSessionCell: View { + let session: LoggerSessionEntity + var isCompact = true + + @Environment(\.store) private var store + @Environment(\.editMode) private var editMode + + var body: some View { + HStack(alignment: .lastTextBaseline) { + Text(session.formattedDate(isCompact: isCompact)) + .fontWeight(store.session.id == session.id ? .medium : .regular) + .lineLimit(1) + .layoutPriority(1) + Spacer() + if let version = session.fullVersion { + Text(version) + .lineLimit(1) + .frame(minWidth: 40) +#if os(macOS) + .foregroundColor(Color(UXColor.tertiaryLabelColor)) +#else + .font(.subheadline) + .foregroundColor(.secondary) +#endif + } + } + .listRowBackground((editMode?.wrappedValue.isEditing ?? false) ? Color.clear : nil) + .tag(session.id) + } +} + +struct SelectedSessionsIDs: Hashable, Identifiable { + var id: SelectedSessionsIDs { self } + let ids: Set +} + +private let sectionTitleFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateStyle = .long + formatter.timeStyle = .none + formatter.doesRelativeDateFormatting = true + return formatter +}() + +#endif diff --git a/Sources/PulseUI/Features/Sessions/SessionsView.swift b/Sources/PulseUI/Features/Sessions/SessionsView.swift new file mode 100644 index 00000000..9ce532b4 --- /dev/null +++ b/Sources/PulseUI/Features/Sessions/SessionsView.swift @@ -0,0 +1,155 @@ +// The MIT License (MIT) +// +// Copyright (c) 2020–2023 Alexander Grebenyuk (github.com/kean). + +import Foundation +import Pulse +import SwiftUI +import CoreData +import Combine + +#if os(iOS) || os(macOS) + +#warning("preselect session on macOS too") +#warning("add sharing on macOS too") +@available(macOS 13, *) +struct SessionsView: View { + @State private var selection: Set = [] + @State private var sharedSessions: SelectedSessionsIDs? + @State private var editMode: EditMode = .inactive + + @EnvironmentObject private var consoleViewModel: ConsoleViewModel + @Environment(\.store) private var store + + var body: some View { + if let version = Version(store.version), version < Version(3, 6, 0) { + PlaceholderView(imageName: "questionmark.app", title: "Unsupported", subtitle: "This feature requires a store created by Pulse version 3.6.0 or higher").padding() + } else { + content + } + } + + @ViewBuilder + private var content: some View { +#if os(macOS) + VStack { + list + HStack { + Spacer() + SearchBar(title: "Filter", imageName: "line.3.horizontal.decrease.circle", text: $filterTerm) + .frame(maxWidth: 220) + }.padding(8) + } +#else + list.toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button(editMode.isEditing ? "Done" : "Edit") { + withAnimation { + editMode = editMode.isEditing ? .inactive : .active + } + } + } + ToolbarItem(placement: .bottomBar) { + if editMode == .active { + bottomBar + } + } + } + .sheet(item: $sharedSessions) { sessions in + NavigationView { + ShareStoreView(sessions: sessions.ids, onDismiss: { sharedSessions = nil }) + }.backport.presentationDetents([.medium]) + } +#endif + } + + private var list: some View { + SessionListView(selection: $selection, sharedSessions: $sharedSessions) +#if os(iOS) + .environment(\.editMode, $editMode) + .onChange(of: selection) { + guard !editMode.isEditing, !$0.isEmpty else { return } + showInConsole(sessions: $0) + } +#else + .onChange(of: selection) { + guard consoleViewModel.searchCriteriaViewModel.criteria.shared.sessions.selection != $0 else { return } + consoleViewModel.searchCriteriaViewModel.select(sessions: $0) + } +#endif + } + +#if os(iOS) + var bottomBar: some View { + HStack { + Button.destructive(action: { + store.removeSessions(withIDs: selection) + }, label: { Image(systemName: "trash") }) + .disabled(selection.isEmpty || selection == [store.session.id]) + + Spacer() + + // It should ideally be done using stringsdict, but Pulse + // doesn't support localization. + if selection.count % 10 == 1 { + Text("\(selection.count) Session Selected") + } else { + Text("\(selection.count) Sessions Selected") + } + + Spacer() + + Button(action: { sharedSessions = SelectedSessionsIDs(ids: selection) }, label: { + Image(systemName: "square.and.arrow.up") + }) + .disabled(selection.isEmpty) + + Menu(content: { + Button("Show in Console") { + showInConsole(sessions: selection) + }.disabled(selection.isEmpty) + }, label: { + Image(systemName: "ellipsis.circle") + }) + } + } + + private func showInConsole(sessions: Set) { + consoleViewModel.searchCriteriaViewModel.select(sessions: sessions) + consoleViewModel.router.isShowingSessions = false + } +#endif + +#if os(macOS) + @ViewBuilder + private func contextMenu(for selection: Set) -> some View { + if !store.isArchive { + Button(role: .destructive, action: { + store.removeSessions(withIDs: selection) + }, label: { Text("Remove") }) + } + } +#endif +} + +#if DEBUG +@available(iOS 15.0, macOS 13, *) +struct Previews_SessionsView_Previews: PreviewProvider { + static let viewModel = ConsoleViewModel(store: .mock) + + static var previews: some View { +#if os(iOS) + NavigationView { + SessionsView() + .background(ConsoleRouterView(viewModel: viewModel)) + .injectingEnvironment(viewModel) + } +#else + SessionsView() + .injectingEnvironment(viewModel) +#endif + } +} +#endif + +#endif diff --git a/Sources/PulseUI/Views/ShareStoreView.swift b/Sources/PulseUI/Views/ShareStoreView.swift index b0fffcef..44f0926b 100644 --- a/Sources/PulseUI/Views/ShareStoreView.swift +++ b/Sources/PulseUI/Views/ShareStoreView.swift @@ -54,7 +54,7 @@ struct ShareStoreView: View { #warning("select sessions fix AND pass current selection") private var sectionSharingOptions: some View { Section { - NavigationLink(destination: ConsoleSessionsView(isPicker: true)) { + NavigationLink(destination: SessionsView()) { HStack { Text("Sessions") Spacer()