diff --git a/CHANGELOG.md b/CHANGELOG.md index 03bc9d4d..b08bfe5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +### 1.28.0 + +- **Add**: `LoggingPresenter`to present list of logs with ability of sharing it +- **Add**: `TILogger` wrapper object to log events. + ### 1.27.1 - **Fix**: Weak target reference in `RefreshControl` diff --git a/LeadKit.podspec b/LeadKit.podspec index 43283b32..369df172 100644 --- a/LeadKit.podspec +++ b/LeadKit.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = "LeadKit" - s.version = "1.27.1" + s.version = "1.28.0" s.summary = "iOS framework with a bunch of tools for rapid development" s.homepage = "https://github.com/TouchInstinct/LeadKit" s.license = "Apache License, Version 2.0" diff --git a/Package.swift b/Package.swift index 0dc55fe0..4816a41b 100644 --- a/Package.swift +++ b/Package.swift @@ -20,6 +20,7 @@ let package = Package( .library(name: "TIFoundationUtils", targets: ["TIFoundationUtils"]), .library(name: "TIKeychainUtils", targets: ["TIKeychainUtils"]), .library(name: "TITableKitUtils", targets: ["TITableKitUtils"]), + .library(name: "TILogging", targets: ["TILogging"]), // MARK: - Networking @@ -64,6 +65,7 @@ let package = Package( .target(name: "TIFoundationUtils", dependencies: ["TISwiftUtils"], path: "TIFoundationUtils"), .target(name: "TIKeychainUtils", dependencies: ["TIFoundationUtils", "KeychainAccess"], path: "TIKeychainUtils/Sources"), .target(name: "TITableKitUtils", dependencies: ["TIUIElements", "TableKit"], path: "TITableKitUtils/Sources"), + .target(name: "TILogging", dependencies: ["TIUIElements", "TISwiftUtils", "TIUIKitCore"], path: "TILogging/Sources"), // MARK: - Networking @@ -84,7 +86,7 @@ let package = Package( .target(name: "TIAuth", dependencies: ["TIFoundationUtils"], path: "TIAuth/Sources"), //MARK: - Skolkovo - .target(name: "TIEcommerce", dependencies: ["TIFoundationUtils", "TISwiftUtils", "TINetworking"], path: "TIEcommerce/Sources"), + .target(name: "TIEcommerce", dependencies: ["TIFoundationUtils", "TISwiftUtils", "TINetworking", "TIUIKitCore", "TIUIElements"], path: "TIEcommerce/Sources"), // MARK: - Tests diff --git a/TIAppleMapUtils/TIAppleMapUtils.podspec b/TIAppleMapUtils/TIAppleMapUtils.podspec index 401c9931..a88c29d5 100644 --- a/TIAppleMapUtils/TIAppleMapUtils.podspec +++ b/TIAppleMapUtils/TIAppleMapUtils.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIAppleMapUtils' - s.version = '1.27.1' + s.version = '1.28.0' s.summary = 'Set of helpers for map objects clustering and interacting using Apple MapKit.' s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/TIAuth/TIAuth.podspec b/TIAuth/TIAuth.podspec index 2f440399..edcb4345 100644 --- a/TIAuth/TIAuth.podspec +++ b/TIAuth/TIAuth.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIAuth' - s.version = '1.27.1' + s.version = '1.28.0' s.summary = 'Login, registration, confirmation and other related actions' s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/TIEcommerce/TIEcommerce.podspec b/TIEcommerce/TIEcommerce.podspec index 1cea5ca7..f5a239d0 100644 --- a/TIEcommerce/TIEcommerce.podspec +++ b/TIEcommerce/TIEcommerce.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIEcommerce' - s.version = '1.27.1' + s.version = '1.28.0' s.summary = 'Cart, products, promocodes, bonuses and other related actions' s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/TIFoundationUtils/TIFoundationUtils.podspec b/TIFoundationUtils/TIFoundationUtils.podspec index 01afb5e8..5acdb115 100644 --- a/TIFoundationUtils/TIFoundationUtils.podspec +++ b/TIFoundationUtils/TIFoundationUtils.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIFoundationUtils' - s.version = '1.27.1' + s.version = '1.28.0' s.summary = 'Set of helpers for Foundation framework classes.' s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/TIGoogleMapUtils/TIGoogleMapUtils.podspec b/TIGoogleMapUtils/TIGoogleMapUtils.podspec index 11afbcb9..c0c47b8a 100644 --- a/TIGoogleMapUtils/TIGoogleMapUtils.podspec +++ b/TIGoogleMapUtils/TIGoogleMapUtils.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIGoogleMapUtils' - s.version = '1.27.1' + s.version = '1.28.0' s.summary = 'Set of helpers for map objects clustering and interacting using Google Maps SDK.' s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/TIKeychainUtils/TIKeychainUtils.podspec b/TIKeychainUtils/TIKeychainUtils.podspec index 27d82aac..52436b16 100644 --- a/TIKeychainUtils/TIKeychainUtils.podspec +++ b/TIKeychainUtils/TIKeychainUtils.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIKeychainUtils' - s.version = '1.27.1' + s.version = '1.28.0' s.summary = 'Set of helpers for Keychain classes.' s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/TILogging/Sources/Logger/Logger.swift b/TILogging/Sources/Logger/Logger.swift new file mode 100644 index 00000000..07507989 --- /dev/null +++ b/TILogging/Sources/Logger/Logger.swift @@ -0,0 +1,77 @@ +// +// Copyright (c) 2022 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import Foundation +import os + +public struct TILogger: LoggerRepresentable { + + // MARK: - Properties + + @available(iOS 12, *) + public static let defaultLogger = TILogger(subsystem: .defaultSubsystem ?? "", category: .pointsOfInterest) + + public let logInfo: OSLog + + // MARK: - Init + + public init(subsystem: String, category: String) { + self.logInfo = .init(subsystem: subsystem, category: category) + } + + @available(iOS 12, *) + public init(subsystem: String, category: OSLog.Category) { + self.logInfo = .init(subsystem: subsystem, category: category) + } + + // MARK: - LoggerRepresentable + + public func log(_ message: StaticString, log: OSLog?, type: OSLogType, _ arguments: CVarArg...) { + os_log(message, log: log ?? logInfo, type: type, arguments) + } + + // MARK: - Public methods + + public func verbose(_ message: StaticString, _ arguments: CVarArg...) { + self.log(message, log: logInfo, type: .default, arguments) + } + + public func info(_ message: StaticString, _ arguments: CVarArg...) { + self.log(message, log: logInfo, type: .info, arguments) + } + + public func debug(_ message: StaticString, _ arguments: CVarArg...) { + self.log(message, log: logInfo, type: .debug, arguments) + } + + public func error(_ message: StaticString, _ arguments: CVarArg...) { + self.log(message, log: logInfo, type: .error, arguments) + } + + public func fault(_ message: StaticString, _ arguments: CVarArg...) { + self.log(message, log: logInfo, type: .fault, arguments) + } +} + +private extension String { + static let defaultSubsystem = Bundle.main.bundleIdentifier +} diff --git a/TILogging/Sources/Logger/LoggerRepresentable.swift b/TILogging/Sources/Logger/LoggerRepresentable.swift new file mode 100644 index 00000000..5337fcfe --- /dev/null +++ b/TILogging/Sources/Logger/LoggerRepresentable.swift @@ -0,0 +1,27 @@ +// +// Copyright (c) 2022 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import os + +public protocol LoggerRepresentable { + func log(_ message: StaticString, log: OSLog?, type: OSLogType, _ arguments: CVarArg...) +} diff --git a/TILogging/Sources/LoggingPresenter.swift b/TILogging/Sources/LoggingPresenter.swift new file mode 100644 index 00000000..63884569 --- /dev/null +++ b/TILogging/Sources/LoggingPresenter.swift @@ -0,0 +1,68 @@ +// +// Copyright (c) 2022 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import UIKit + +/// A presenter of a window on which list of logs can be opened. +@available(iOS 15, *) +final public class LoggingPresenter { + + public static let shared = LoggingPresenter() + + private var window: LoggingTogglingWindow? + + private init() { } + + /// Binds openning and closing of logging list view to a shaking motion. + public func displayOnShakeEvent(withWindow window: LoggingTogglingWindow) { + self.window = window + + window.set(isVisible: false) + window.set(isRegisteredForShakingEvent: true) + } + + /// Shows the UIWindow with a button that opens a logging list view. + public func addLogsButton(to scene: UIWindowScene? = nil, + windowRect rect: CGRect = UIScreen.main.bounds) { + + window = .init(frame: rect) + showWindow(withScene: scene) + + window?.set(isVisible: true) + window?.set(isRegisteredForShakingEvent: false) + } + + /// Shows the UIWindow + public func showWindow(withScene scene: UIWindowScene? = nil) { + if let scene = scene { + window?.windowScene = scene + } + + window?.makeKeyAndVisible() + } + + /// Hides the UIWindow. + public func hideWindow() { + window?.isHidden = true + window?.set(isVisible: true) + } +} diff --git a/TILogging/Sources/Views/LoggerList/LogEntryCellView.swift b/TILogging/Sources/Views/LoggerList/LogEntryCellView.swift new file mode 100644 index 00000000..32e10e33 --- /dev/null +++ b/TILogging/Sources/Views/LoggerList/LogEntryCellView.swift @@ -0,0 +1,86 @@ +// +// Copyright (c) 2022 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import TIUIElements +import UIKit + +open class LogEntryCellView: BaseInitializableView { + + public let containerView = UIStackView() + public let timeLabel = UILabel() + public let processLabel = UILabel() + public let messageLabel = UILabel() + public let levelTypeView = UIView() + + // MARK: - Life cycle + + open override func addViews() { + super.addViews() + + containerView.addArrangedSubviews(timeLabel, processLabel) + addSubviews(containerView, + messageLabel, + levelTypeView) + } + + open override func configureLayout() { + super.configureLayout() + + [containerView, timeLabel, processLabel, messageLabel, levelTypeView] + .forEach { $0.translatesAutoresizingMaskIntoConstraints = false } + + NSLayoutConstraint.activate([ + containerView.centerYAnchor.constraint(equalTo: centerYAnchor), + containerView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 16), + containerView.topAnchor.constraint(equalTo: topAnchor, constant: 8), + containerView.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8), + + messageLabel.leadingAnchor.constraint(equalTo: containerView.trailingAnchor, constant: 8), + messageLabel.topAnchor.constraint(equalTo: topAnchor, constant: 8), + messageLabel.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -8), + messageLabel.trailingAnchor.constraint(equalTo: levelTypeView.leadingAnchor, constant: -8), + + levelTypeView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -16), + levelTypeView.centerYAnchor.constraint(equalTo: centerYAnchor), + levelTypeView.heightAnchor.constraint(equalToConstant: 10), + levelTypeView.widthAnchor.constraint(equalToConstant: 10), + ]) + } + + open override func configureAppearance() { + super.configureAppearance() + + containerView.axis = .vertical + containerView.spacing = 4 + + timeLabel.font = .systemFont(ofSize: 10) + + processLabel.lineBreakMode = .byTruncatingTail + processLabel.font = .systemFont(ofSize: 12) + + messageLabel.numberOfLines = 0 + messageLabel.lineBreakMode = .byWordWrapping + messageLabel.font = .systemFont(ofSize: 12) + + levelTypeView.layer.cornerRadius = 3 + } +} diff --git a/TILogging/Sources/Views/LoggerList/LogEntryTableViewCell.swift b/TILogging/Sources/Views/LoggerList/LogEntryTableViewCell.swift new file mode 100644 index 00000000..1e1fb78f --- /dev/null +++ b/TILogging/Sources/Views/LoggerList/LogEntryTableViewCell.swift @@ -0,0 +1,57 @@ +// +// Copyright (c) 2022 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import TIUIKitCore +import TIUIElements +import UIKit +import OSLog + +@available(iOS 15, *) +open class LogEntryTableViewCell: ContainerTableViewCell, ConfigurableView { + + // MARK: - ConfigurableView + + open func configure(with entry: OSLogEntryLog) { + wrappedView.timeLabel.text = entry.date.formatted() + wrappedView.processLabel.text = entry.process + wrappedView.messageLabel.text = entry.composedMessage + + configureType(withLevel: entry.level) + } + + // MARK: - Open methods + + open func configureType(withLevel level: OSLogEntryLog.Level) { + switch level { + case .error: + wrappedView.levelTypeView.isHidden = false + wrappedView.levelTypeView.backgroundColor = .yellow + + case .fault: + wrappedView.levelTypeView.isHidden = false + wrappedView.levelTypeView.backgroundColor = .red + + default: + wrappedView.levelTypeView.isHidden = true + } + } +} diff --git a/TILogging/Sources/Views/LoggerList/LogsListViewController.swift b/TILogging/Sources/Views/LoggerList/LogsListViewController.swift new file mode 100644 index 00000000..7168311a --- /dev/null +++ b/TILogging/Sources/Views/LoggerList/LogsListViewController.swift @@ -0,0 +1,300 @@ +// +// Copyright (c) 2022 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import TISwiftUtils +import TIUIKitCore +import OSLog +import UIKit + +@available(iOS 15, *) +open class LogsListViewController: BaseInitializeableViewController, + LogsListViewOutput, + AlertPresentationContext, + UISearchBarDelegate, + UITextFieldDelegate { + + private var timer: Timer? + + public enum TableSection: String, Hashable { + case main + } + + public let activityView = UIActivityIndicatorView() + public let searchView = UISearchBar() + public let segmentView = UISegmentedControl() + public let refreshControl = UIRefreshControl() + public let shareButton = UIButton() + public let tableView = UITableView() + + lazy private var dataSource = createDataSource() + + public typealias DataSource = UITableViewDiffableDataSource + public typealias Snapshot = NSDiffableDataSourceSnapshot + + public let viewModel = LogsStorageViewModel() + public var logsFileExtension = "log" + + // MARK: - Life cycle + + open override func viewDidLoad() { + super.viewDidLoad() + + loadLogs() + } + + open override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + applySnapshot() + } + + open override func addViews() { + super.addViews() + + tableView.addSubview(refreshControl) + + view.addSubviews(searchView, + segmentView, + shareButton, + tableView, + activityView) + } + + open override func configureLayout() { + super.configureLayout() + + [searchView, segmentView, shareButton, tableView, activityView] + .forEach { $0.translatesAutoresizingMaskIntoConstraints = false } + + NSLayoutConstraint.activate([ + searchView.topAnchor.constraint(equalTo: view.topAnchor, constant: 16), + searchView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + searchView.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + searchView.heightAnchor.constraint(equalToConstant: 32), + + segmentView.topAnchor.constraint(equalTo: searchView.bottomAnchor, constant: 8), + segmentView.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 16), + segmentView.trailingAnchor.constraint(equalTo: shareButton.leadingAnchor, constant: -8), + segmentView.heightAnchor.constraint(equalToConstant: 32), + + shareButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -16), + shareButton.centerYAnchor.constraint(equalTo: segmentView.centerYAnchor), + shareButton.heightAnchor.constraint(equalToConstant: 32), + shareButton.widthAnchor.constraint(equalToConstant: 32), + + tableView.topAnchor.constraint(equalTo: segmentView.bottomAnchor, constant: 8), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + activityView.centerXAnchor.constraint(equalTo: tableView.centerXAnchor), + activityView.centerYAnchor.constraint(equalTo: tableView.centerYAnchor), + activityView.heightAnchor.constraint(equalToConstant: 32), + activityView.widthAnchor.constraint(equalToConstant: 32), + ]) + } + + open override func bindViews() { + super.bindViews() + + viewModel.logsListView = self + searchView.delegate = self + tableView.register(LogEntryTableViewCell.self, + forCellReuseIdentifier: LogEntryTableViewCell.reuseIdentifier) + refreshControl.addTarget(self, action: #selector(reloadLogs), for: .valueChanged) + shareButton.addTarget(self, action: #selector(shareLogs), for: .touchUpInside) + } + + open override func configureAppearance() { + super.configureAppearance() + + configureSegmentView() + configureReloadButton() + configureSearchView() + + view.backgroundColor = .systemBackground + tableView.backgroundColor = .systemBackground + } + + // MARK: - LogsListViewOutput + + open func reloadTableView() { + applySnapshot() + } + + open func setLoadingState() { + view.subviews.forEach { $0.isUserInteractionEnabled = false } + startLoadingAnimation() + } + + open func setNormalState() { + view.subviews.forEach { $0.isUserInteractionEnabled = true } + stopLoadingAnimation() + } + + open func startSearch() { + [shareButton, segmentView, tableView] + .forEach { $0.isUserInteractionEnabled = false } + startLoadingAnimation() + } + + open func stopSearch() { + [shareButton, segmentView, tableView] + .forEach { $0.isUserInteractionEnabled = true } + stopLoadingAnimation() + } + + // MARK: - UISearchBarDelegate + UITextFieldDelegate + + open func searchBar(_ searchBar: UISearchBar, textDidChange searchText: String) { + guard !searchText.isEmpty else { + filterLogsByText() + return + } + + timer?.invalidate() + timer = Timer.scheduledTimer(timeInterval: 1, + target: self, + selector: #selector(filterLogsByText), + userInfo: nil, + repeats: false) + } + + open func searchBarResultsListButtonClicked(_ searchBar: UISearchBar) { + searchView.resignFirstResponder() + } + + open func textFieldDidEndEditing(_ textField: UITextField) { + viewModel.fileCreator = .init(fileName: textField.text ?? "", fileExtension: logsFileExtension) + } + + // MARK: - Open methods + + open func createDataSource() -> DataSource { + let cellProvider: DataSource.CellProvider = { collectionView, indexPath, itemIdentifier in + let cell = collectionView.dequeueReusableCell(withIdentifier: LogEntryTableViewCell.reuseIdentifier, + for: indexPath) as? LogEntryTableViewCell + + cell?.configure(with: itemIdentifier) + + return cell + } + + return .init(tableView: tableView, cellProvider: cellProvider) + } + + open func applySnapshot() { + var snapshot = Snapshot() + + snapshot.appendSections([TableSection.main.rawValue]) + snapshot.appendItems(viewModel.filteredLogs, toSection: TableSection.main.rawValue) + + dataSource.apply(snapshot, animatingDifferences: true) + } + + open func startLoadingAnimation() { + activityView.isHidden = false + activityView.startAnimating() + } + + open func stopLoadingAnimation() { + activityView.isHidden = true + activityView.stopAnimating() + } + + // MARK: - Private methods + + private func configureSegmentView() { + for (index, segment) in LogsStorageViewModel.LevelType.allCases.enumerated() { + let action = UIAction(title: segment.rawValue, handler: viewModel.actionHandler(for:)) + segmentView.insertSegment(action: action, at: index, animated: false) + } + + segmentView.selectedSegmentIndex = 0 + } + + private func configureReloadButton() { + shareButton.setImage(UIImage(systemName: "square.and.arrow.up"), for: .normal) + } + + private func configureSearchView() { + searchView.placeholder = "Date, subcategory, message text, etc." + searchView.layer.backgroundColor = UIColor.systemBackground.cgColor + searchView.searchBarStyle = .minimal + } + + private func startSharingFlow() { + let alert = AlertFactory().alert(title: "Enter file name", actions: [ + .init(title: "Cancel", style: .cancel, action: nil), + .init(title: "Share", style: .default, action: { [weak self] in + self?.presentShareScreen() + }) + ]) + + alert.present(on: self, alertViewFactory: { configuration in + let alertController = UIAlertController(title: "", message: "", preferredStyle: .alert) + .configured(with: configuration) + + alertController.addTextField(configurationHandler: { textField in + textField.delegate = self + }) + + return alertController + }) + } + + private func presentShareScreen() { + guard let file = viewModel.getFileWithLogs() else { + AlertFactory().retryAlert(title: "Can't create a file with name: {\(viewModel.fileCreator?.fullFileName ?? "")}", + retryAction: { [weak self] in + self?.startSharingFlow() + }).present(on: self) + return + } + + let activityViewController = UIActivityViewController(activityItems: [file], + applicationActivities: nil) + present(activityViewController, animated: true, completion: nil) + } + + private func loadLogs(preCompletion: VoidClosure? = nil, postCompletion: VoidClosure? = nil) { + Task { + await viewModel.loadLogs(preCompletion: preCompletion, postCompletion: postCompletion) + } + } + + // MARK: - Actions + + @objc private func reloadLogs() { + loadLogs(preCompletion: stopLoadingAnimation, postCompletion: refreshControl.endRefreshing) + } + + @objc private func shareLogs() { + startSharingFlow() + } + + @objc private func filterLogsByText() { + Task { + await viewModel.filterLogs(byText: searchView.text ?? "") + } + } +} diff --git a/TILogging/Sources/Views/LoggerWindow/LoggingTogglingViewController.swift b/TILogging/Sources/Views/LoggerWindow/LoggingTogglingViewController.swift new file mode 100644 index 00000000..5a03f6fc --- /dev/null +++ b/TILogging/Sources/Views/LoggerWindow/LoggingTogglingViewController.swift @@ -0,0 +1,176 @@ +// +// Copyright (c) 2022 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import TIUIKitCore +import UIKit + +@available(iOS 15, *) +open class LoggingTogglingViewController: BaseInitializeableViewController { + + private var initialCenter = CGPoint() + + private(set) public var isRegisteredForShakingEvent = false + + private(set) public var isLogsPresented = false + + private(set) public var isVisibleState = true { + didSet { + button.isHidden = !isVisibleState + } + } + + public let button = UIButton() + + open override var canBecomeFirstResponder: Bool { true } + + // MARK: - Life cycle + + open override func addViews() { + super.addViews() + + view.addSubview(button) + } + + open override func configureLayout() { + super.configureLayout() + + let safeAreaFrame = view.safeAreaLayoutGuide.layoutFrame + button.frame = .init(x: safeAreaFrame.minX, + y: safeAreaFrame.midY, + width: 70, + height: 32) + } + + open override func bindViews() { + super.bindViews() + + let panGesture = UIPanGestureRecognizer(target: self, action: #selector(handlePanGesture(gesture:))) + + button.addGestureRecognizer(panGesture) + button.addTarget(self, action: #selector(openLoggingScreenAction), for: .touchUpInside) + + // Needed for catching shaking motion + becomeFirstResponder() + } + + open override func configureAppearance() { + super.configureAppearance() + + view.backgroundColor = .clear + + button.setTitle("Logs", for: .normal) + button.setTitleColor(.black, for: .normal) + button.backgroundColor = .white + button.layer.cornerRadius = 10 + button.layer.borderWidth = 1 + button.layer.borderColor = UIColor.gray.cgColor + } + + // MARK: - Overrided methods + + open override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) { + if motion == .motionShake, !isLogsPresented, isRegisteredForShakingEvent { + openLoggingScreen() + } + } + + // MARK: - Public methods + + public func set(isRegisteredForShakingEvent: Bool) { + self.isRegisteredForShakingEvent = isRegisteredForShakingEvent + } + + /// Hides a button that opens logs list view controller. + public func set(isVisible: Bool) { + isVisibleState = isVisible + } + + public func set(isLogsPresented: Bool) { + self.isLogsPresented = isLogsPresented + } + + public func openLoggingScreen() { + present(LogsListViewController(), animated: true, completion: { [self] in + isLogsPresented = false + }) + + isLogsPresented = true + } + + // MARK: - Private methods + + private func clipButtonIfNeeded() { + let viewFrame = view.safeAreaLayoutGuide.layoutFrame + let buttonFrame = button.frame + var x = buttonFrame.minX + var y = buttonFrame.minY + + if buttonFrame.maxX > viewFrame.maxX { + x = viewFrame.maxX - buttonFrame.width + y = buttonFrame.minY + + } else if buttonFrame.minX < viewFrame.minX { + x = viewFrame.minX + y = buttonFrame.minY + } + + if buttonFrame.maxY > viewFrame.maxY { + x = buttonFrame.minX + y = viewFrame.maxY - buttonFrame.height + + } else if buttonFrame.minY < viewFrame.minY { + x = buttonFrame.minX + y = viewFrame.minY + } + + button.frame = .init(x: x, + y: y, + width: buttonFrame.width, + height: buttonFrame.height) + } + + // MARK: - Actions + + @objc private func handlePanGesture(gesture: UIPanGestureRecognizer) { + let translation = gesture.translation(in: view) + + switch gesture.state { + case .began: + self.initialCenter = button.center + + case .changed, .ended: + let newCenter = CGPoint(x: initialCenter.x + translation.x, y: initialCenter.y + translation.y) + button.center = newCenter + clipButtonIfNeeded() + + case .cancelled: + button.center = initialCenter + + default: + break + } + } + + @objc private func openLoggingScreenAction() { + openLoggingScreen() + } +} diff --git a/TILogging/Sources/Views/LoggerWindow/LoggingTogglingWindow.swift b/TILogging/Sources/Views/LoggerWindow/LoggingTogglingWindow.swift new file mode 100644 index 00000000..92f66cbc --- /dev/null +++ b/TILogging/Sources/Views/LoggerWindow/LoggingTogglingWindow.swift @@ -0,0 +1,106 @@ +// +// Copyright (c) 2022 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import TIUIKitCore +import UIKit + +@available(iOS 15, *) +public final class LoggingTogglingWindow: UIWindow { + + let loggingController = LoggingTogglingViewController() + + override public init(windowScene: UIWindowScene) { + super.init(windowScene: windowScene) + } + + override public init(frame: CGRect) { + super.init(frame: frame) + + rootViewController = loggingController + windowLevel = .statusBar + backgroundColor = .clear + } + + @available(*, unavailable) + required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + public override func motionEnded(_ motion: UIEvent.EventSubtype, with event: UIEvent?) { + if motion == .motionShake, + !loggingController.isLogsPresented { + openLoggingScreen() + + } else { + super.motionEnded(motion, with: event) + } + } + + public override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { + guard let rootView = rootViewController else { return false } + + if let loggingController = rootView.presentedViewController { + return loggingController.view.point(inside: point, with: event) + } + + if let button = rootView.view.subviews.first { + let buttonPoint = convert(point, to: button) + return button.point(inside: buttonPoint, with: event) + } + + return false + } + + // MARK: - Public methdos + + public func set(isRegisteredForShakingEvent: Bool) { + loggingController.set(isRegisteredForShakingEvent: isRegisteredForShakingEvent) + } + + public func set(isVisible: Bool) { + loggingController.set(isVisible: isVisible) + } + + // MARK: - Private methods + + private func openLoggingScreen() { + if loggingController.isRegisteredForShakingEvent { + openLoggingScreenOnTopViewController() + } else { + loggingController.openLoggingScreen() + } + } + + private func openLoggingScreenOnTopViewController() { + guard let rootViewController = rootViewController else { + return + } + + let topViewController = rootViewController.topVisibleViewController + + topViewController.present(LogsListViewController(), animated: true, completion: { [weak self] in + self?.loggingController.set(isLogsPresented: false) + }) + + loggingController.set(isLogsPresented: true) + } +} diff --git a/TILogging/Sources/Views/ViewModels/FileCreator.swift b/TILogging/Sources/Views/ViewModels/FileCreator.swift new file mode 100644 index 00000000..b785dd5a --- /dev/null +++ b/TILogging/Sources/Views/ViewModels/FileCreator.swift @@ -0,0 +1,67 @@ +// +// Copyright (c) 2022 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import Foundation + +public struct FileCreator { + + public var fileName: String + public var fileExtension: String + + public var fullFileName: String { + fileName + "." + fileExtension + } + + public init(fileName: String, fileExtension: String) { + self.fileName = fileName + self.fileExtension = fileExtension + } + + @discardableResult + public func createFile(withData data: Data) -> Result { + let result = getDocumentsDirectory() + + guard case var .success(url) = result else { + return result + } + + url.appendPathComponent(fullFileName) + + do { + try data.write(to: url) + return .success(url) + + } catch { + return .failure(error) + } + + } + + public func getDocumentsDirectory() -> Result { + Result { + try FileManager.default.url(for: .documentDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: false) + } + } +} diff --git a/TILogging/Sources/Views/ViewModels/Helpers/DefaultLogsListManipulator.swift b/TILogging/Sources/Views/ViewModels/Helpers/DefaultLogsListManipulator.swift new file mode 100644 index 00000000..07246611 --- /dev/null +++ b/TILogging/Sources/Views/ViewModels/Helpers/DefaultLogsListManipulator.swift @@ -0,0 +1,50 @@ +// +// Copyright (c) 2022 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import OSLog + +@available(iOS 15, *) +public actor DefaultLogsListManipulator: LogsListManipulatorProtocol { + public func fetchLogs() async -> Result<[OSLogEntryLog], Error> { + Result { try OSLogStore(scope: .currentProcessIdentifier) } + .flatMap { logStore in + Result { + try logStore + .getEntries() + .reversed() + .compactMap { $0 as? OSLogEntryLog } + } + } + } + + public func filter(_ logs: [OSLogEntryLog], byText text: String) async -> [OSLogEntryLog] { + logs.filter { log in + let isDate = log.date.formatted().contains(text) + let isMessage = log.composedMessage.contains(text) + let isCategory = log.category.contains(text) + let isSubsystem = log.subsystem.contains(text) + let isProcess = log.process.contains(text) + + return isDate || isMessage || isCategory || isSubsystem || isProcess + } + } +} diff --git a/TILogging/Sources/Views/ViewModels/Helpers/LogsListManipulatorProtocol.swift b/TILogging/Sources/Views/ViewModels/Helpers/LogsListManipulatorProtocol.swift new file mode 100644 index 00000000..2e229afd --- /dev/null +++ b/TILogging/Sources/Views/ViewModels/Helpers/LogsListManipulatorProtocol.swift @@ -0,0 +1,29 @@ +// +// Copyright (c) 2022 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import OSLog + +@available(iOS 15, *) +public protocol LogsListManipulatorProtocol { + func fetchLogs() async -> Result<[OSLogEntryLog], Error> + func filter(_ logs: [OSLogEntryLog], byText text: String) async -> [OSLogEntryLog] +} diff --git a/TILogging/Sources/Views/ViewModels/LogsListViewOutput.swift b/TILogging/Sources/Views/ViewModels/LogsListViewOutput.swift new file mode 100644 index 00000000..bc52d09a --- /dev/null +++ b/TILogging/Sources/Views/ViewModels/LogsListViewOutput.swift @@ -0,0 +1,29 @@ +// +// Copyright (c) 2022 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +public protocol LogsListViewOutput: AnyObject { + func reloadTableView() + func setLoadingState() + func setNormalState() + func startSearch() + func stopSearch() +} \ No newline at end of file diff --git a/TILogging/Sources/Views/ViewModels/LogsStorageViewModel.swift b/TILogging/Sources/Views/ViewModels/LogsStorageViewModel.swift new file mode 100644 index 00000000..fb04c0d1 --- /dev/null +++ b/TILogging/Sources/Views/ViewModels/LogsStorageViewModel.swift @@ -0,0 +1,142 @@ +// +// Copyright (c) 2022 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the Software), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED AS IS, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import TISwiftUtils +import UIKit +import OSLog + +@available(iOS 15, *) +open class LogsStorageViewModel { + + public enum LevelType: String, CaseIterable { + case all + case `default` + case info + case debug + case error + case fault + } + + private let logsManipulator: LogsListManipulatorProtocol + + private var allLogs: [OSLogEntryLog] = [] { + didSet { + filterLogs() + } + } + + public var filteredLogs: [OSLogEntryLog] = [] { + didSet { + logsListView?.reloadTableView() + } + } + + public var fileCreator: FileCreator? + weak public var logsListView: LogsListViewOutput? + + public init(logsManipulator: LogsListManipulatorProtocol? = nil) { + self.logsManipulator = logsManipulator ?? DefaultLogsListManipulator() + } + + @MainActor + open func loadLogs(preCompletion: VoidClosure? = nil, postCompletion: VoidClosure? = nil) async { + allLogs = [] + logsListView?.setLoadingState() + preCompletion?() + + if case let .success(logs) = await logsManipulator.fetchLogs() { + allLogs = logs + } + + logsListView?.setNormalState() + postCompletion?() + } + + open func filterLogs(filter: Closure? = nil) { + filteredLogs = allLogs.filter { filter?($0) ?? true } + } + + @MainActor + open func filterLogs(byText text: String) async { + guard !text.isEmpty else { + filteredLogs = allLogs + return + } + + logsListView?.startSearch() + + let localFilteredLogs = await logsManipulator.filter(allLogs, byText: text) + + filteredLogs = localFilteredLogs + logsListView?.stopSearch() + } + + open func getFileWithLogs() -> URL? { + guard let data = encodeLogs() else { + return nil + } + + if case let .success(url) = fileCreator?.createFile(withData: data) { + return url + } + + return nil + } + + public func encodeLogs() -> Data? { + filteredLogs + .map { $0.getDescription() } + .joined(separator: "\n\n") + .data(using: .utf8) + } + + open func actionHandler(for action: UIAction) { + guard let level = LevelType(rawValue: action.title) else { return } + + switch level { + case .all: + filterLogs() + + case .info: + filterLogs(filter: { $0.level == .info }) + + case .default: + filterLogs(filter: { $0.level == .notice }) + + case .debug: + filterLogs(filter: { $0.level == .debug }) + + case .error: + filterLogs(filter: { $0.level == .error }) + + case .fault: + filterLogs(filter: { $0.level == .fault }) + } + } +} + +@available(iOS 15, *) +private extension OSLogEntryLog { + func getDescription() -> String { + return "[\(date)] - [\(category)]: \(composedMessage)" + } +} diff --git a/TILogging/TILogging.podspec b/TILogging/TILogging.podspec new file mode 100644 index 00000000..c82ee99e --- /dev/null +++ b/TILogging/TILogging.podspec @@ -0,0 +1,20 @@ +Pod::Spec.new do |s| + s.name = 'TILogging' + s.version = '1.28.0' + s.summary = 'Logging API' + s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name + s.license = { :type => 'MIT', :file => 'LICENSE' } + s.author = { 'petropavel13' => 'ivan.smolin@touchin.ru', + 'castlele' => 'nikita.semenov@touchin.ru' } + s.source = { :git => 'https://github.com/TouchInstinct/LeadKit.git', :tag => s.version.to_s } + + s.ios.deployment_target = '10.0' + s.swift_versions = ['5.3'] + + s.source_files = s.name + '/Sources/**/*' + + s.dependency 'TIUIKitCore', s.version.to_s + s.dependency 'TISwiftUtils', s.version.to_s + s.dependency 'TIUIElements', s.version.to_s + +end diff --git a/TIMapUtils/TIMapUtils.podspec b/TIMapUtils/TIMapUtils.podspec index bd1760f5..e6fad592 100644 --- a/TIMapUtils/TIMapUtils.podspec +++ b/TIMapUtils/TIMapUtils.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIMapUtils' - s.version = '1.27.1' + s.version = '1.28.0' s.summary = 'Set of helpers for map objects clustering and interacting.' s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/TIMoyaNetworking/TIMoyaNetworking.podspec b/TIMoyaNetworking/TIMoyaNetworking.podspec index 92591eca..29255b7d 100644 --- a/TIMoyaNetworking/TIMoyaNetworking.podspec +++ b/TIMoyaNetworking/TIMoyaNetworking.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIMoyaNetworking' - s.version = '1.27.1' + s.version = '1.28.0' s.summary = 'Moya + Swagger network service.' s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/TINetworking/TINetworking.podspec b/TINetworking/TINetworking.podspec index d601e7cb..bf319b5d 100644 --- a/TINetworking/TINetworking.podspec +++ b/TINetworking/TINetworking.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TINetworking' - s.version = '1.27.1' + s.version = '1.28.0' s.summary = 'Swagger-frendly networking layer helpers.' s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/TINetworkingCache/TINetworkingCache.podspec b/TINetworkingCache/TINetworkingCache.podspec index e9552a73..ae2c4a47 100644 --- a/TINetworkingCache/TINetworkingCache.podspec +++ b/TINetworkingCache/TINetworkingCache.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TINetworkingCache' - s.version = '1.27.1' + s.version = '1.28.0' s.summary = 'Caching results of EndpointRequests.' s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/TIPagination/TIPagination.podspec b/TIPagination/TIPagination.podspec index ece868b4..03da19a7 100644 --- a/TIPagination/TIPagination.podspec +++ b/TIPagination/TIPagination.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIPagination' - s.version = '1.27.1' + s.version = '1.28.0' s.summary = 'Generic pagination component.' s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/TISwiftUICore/TISwiftUICore.podspec b/TISwiftUICore/TISwiftUICore.podspec index b24d0052..0d0af9e3 100644 --- a/TISwiftUICore/TISwiftUICore.podspec +++ b/TISwiftUICore/TISwiftUICore.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TISwiftUICore' - s.version = '1.27.1' + s.version = '1.28.0' s.summary = 'Core UI elements: protocols, views and helpers..' s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/TISwiftUtils/TISwiftUtils.podspec b/TISwiftUtils/TISwiftUtils.podspec index 8dd5e209..bc8fcac9 100644 --- a/TISwiftUtils/TISwiftUtils.podspec +++ b/TISwiftUtils/TISwiftUtils.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TISwiftUtils' - s.version = '1.27.1' + s.version = '1.28.0' s.summary = 'Bunch of useful helpers for Swift development.' s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/TITableKitUtils/TITableKitUtils.podspec b/TITableKitUtils/TITableKitUtils.podspec index fcffaacb..159dd3ab 100644 --- a/TITableKitUtils/TITableKitUtils.podspec +++ b/TITableKitUtils/TITableKitUtils.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TITableKitUtils' - s.version = '1.27.1' + s.version = '1.28.0' s.summary = 'Set of helpers for TableKit classes.' s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/TITransitions/TITransitions.podspec b/TITransitions/TITransitions.podspec index b577863b..b1b7dc29 100644 --- a/TITransitions/TITransitions.podspec +++ b/TITransitions/TITransitions.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TITransitions' - s.version = '1.27.1' + s.version = '1.28.0' s.summary = 'Set of custom transitions to present controller. ' s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/TIUIElements/TIUIElements.podspec b/TIUIElements/TIUIElements.podspec index 2cbe329c..a724f255 100644 --- a/TIUIElements/TIUIElements.podspec +++ b/TIUIElements/TIUIElements.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIUIElements' - s.version = '1.27.1' + s.version = '1.28.0' s.summary = 'Bunch of useful protocols and views.' s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/TIUIKitCore/Sources/Extensions/UIViewController/UIViewController+TopVisible.swift b/TIUIKitCore/Sources/Extensions/UIViewController/UIViewController+TopVisible.swift new file mode 100644 index 00000000..3c8266e7 --- /dev/null +++ b/TIUIKitCore/Sources/Extensions/UIViewController/UIViewController+TopVisible.swift @@ -0,0 +1,40 @@ +// +// Copyright (c) 2022 Touch Instinct +// +// Permission is hereby granted, free of charge, to any person obtaining a copy +// of this software and associated documentation files (the "Software"), to deal +// in the Software without restriction, including without limitation the rights +// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +// copies of the Software, and to permit persons to whom the Software is +// furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included in +// all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +// THE SOFTWARE. +// + +import UIKit + +public extension UIViewController { + + /// Return top visible controller even if we have inner UI(Navigation/TabBar)Controller's inside + var topVisibleViewController: UIViewController { + switch self { + case let navController as UINavigationController: + return navController.visibleViewController?.topVisibleViewController ?? navController + + case let tabController as UITabBarController: + return tabController.selectedViewController?.topVisibleViewController ?? tabController + + default: + return self.presentedViewController?.topVisibleViewController ?? self + } + } +} diff --git a/TIUIKitCore/TIUIKitCore.podspec b/TIUIKitCore/TIUIKitCore.podspec index 0d164efc..e713c4ce 100644 --- a/TIUIKitCore/TIUIKitCore.podspec +++ b/TIUIKitCore/TIUIKitCore.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIUIKitCore' - s.version = '1.27.1' + s.version = '1.28.0' s.summary = 'Core UI elements: protocols, views and helpers.' s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' } diff --git a/TIYandexMapUtils/TIYandexMapUtils.podspec b/TIYandexMapUtils/TIYandexMapUtils.podspec index 2f6e47cf..1efed4d0 100644 --- a/TIYandexMapUtils/TIYandexMapUtils.podspec +++ b/TIYandexMapUtils/TIYandexMapUtils.podspec @@ -1,6 +1,6 @@ Pod::Spec.new do |s| s.name = 'TIYandexMapUtils' - s.version = '1.27.1' + s.version = '1.28.0' s.summary = 'Set of helpers for map objects clustering and interacting using Yandex Maps SDK.' s.homepage = 'https://github.com/TouchInstinct/LeadKit/tree/' + s.version.to_s + '/' + s.name s.license = { :type => 'MIT', :file => 'LICENSE' }