diff --git a/iOS/Debugger/UI/ViewControllers/BreakpointsViewController.swift b/iOS/Debugger/UI/ViewControllers/BreakpointsViewController.swift new file mode 100644 index 0000000..45431b0 --- /dev/null +++ b/iOS/Debugger/UI/ViewControllers/BreakpointsViewController.swift @@ -0,0 +1,145 @@ +import UIKit + +/// View controller for managing breakpoints +class BreakpointsViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { + // MARK: - Properties + + private let tableView = UITableView() + private let debuggerEngine = DebuggerEngine.shared + private var breakpoints: [Breakpoint] = [] + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + setupUI() + setupTableView() + loadBreakpoints() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + // Refresh breakpoints when view appears + loadBreakpoints() + } + + // MARK: - Setup + + private func setupUI() { + title = "Breakpoints" + view.backgroundColor = .systemBackground + + // Add add button + navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .add, + target: self, + action: #selector(addBreakpoint) + ) + } + + private func setupTableView() { + tableView.dataSource = self + tableView.delegate = self + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "BreakpointCell") + + view.addSubview(tableView) + tableView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor) + ]) + } + + // MARK: - Data + + private func loadBreakpoints() { + breakpoints = debuggerEngine.getBreakpoints() + tableView.reloadData() + } + + @objc private func addBreakpoint() { + let alert = UIAlertController( + title: "Add Breakpoint", + message: "Enter file path and line number", + preferredStyle: .alert + ) + + alert.addTextField { textField in + textField.placeholder = "File path (e.g., ViewController.swift)" + } + + alert.addTextField { textField in + textField.placeholder = "Line number" + textField.keyboardType = .numberPad + } + + alert.addAction(UIAlertAction(title: "Cancel", style: .cancel)) + + alert.addAction(UIAlertAction(title: "Add", style: .default) { [weak self] _ in + guard let self = self, + let fileText = alert.textFields?[0].text, !fileText.isEmpty, + let lineText = alert.textFields?[1].text, !lineText.isEmpty, + let lineNumber = Int(lineText) else { + return + } + + // Add breakpoint + let breakpoint = Breakpoint(file: fileText, line: lineNumber) + self.debuggerEngine.addBreakpoint(breakpoint) + + // Refresh list + self.loadBreakpoints() + }) + + present(alert, animated: true) + } + + // MARK: - UITableViewDataSource + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return breakpoints.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "BreakpointCell", for: indexPath) + + let breakpoint = breakpoints[indexPath.row] + + // Configure cell + if #available(iOS 14.0, *) { + var content = cell.defaultContentConfiguration() + content.text = breakpoint.file + content.secondaryText = "Line \(breakpoint.line)" + content.image = UIImage(systemName: "pause.circle.fill") + content.imageProperties.tintColor = .systemRed + cell.contentConfiguration = content + } else { + // Fallback for older iOS versions + cell.textLabel?.text = breakpoint.file + cell.detailTextLabel?.text = "Line \(breakpoint.line)" + cell.imageView?.image = UIImage(systemName: "pause.circle.fill") + cell.imageView?.tintColor = .systemRed + } + + return cell + } + + // MARK: - UITableViewDelegate + + func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { + if editingStyle == .delete { + // Remove breakpoint + let breakpoint = breakpoints[indexPath.row] + debuggerEngine.removeBreakpoint(breakpoint) + + // Update data + breakpoints.remove(at: indexPath.row) + tableView.deleteRows(at: [indexPath], with: .fade) + } + } +} diff --git a/iOS/Debugger/UI/ViewControllers/ConsoleViewController.swift b/iOS/Debugger/UI/ViewControllers/ConsoleViewController.swift new file mode 100644 index 0000000..99f38bd --- /dev/null +++ b/iOS/Debugger/UI/ViewControllers/ConsoleViewController.swift @@ -0,0 +1,154 @@ +import UIKit + +/// View controller for displaying console logs +class ConsoleViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { + // MARK: - Properties + + private let tableView = UITableView() + private let logger = Debug.shared + private var logs: [LogEntry] = [] + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + setupUI() + setupTableView() + loadLogs() + + // Register for log notifications + NotificationCenter.default.addObserver( + self, + selector: #selector(handleNewLog), + name: .newLogEntry, + object: nil + ) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // MARK: - Setup + + private func setupUI() { + title = "Console" + view.backgroundColor = .systemBackground + + // Add clear button + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "Clear", + style: .plain, + target: self, + action: #selector(clearLogs) + ) + } + + private func setupTableView() { + tableView.dataSource = self + tableView.delegate = self + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "LogCell") + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 60 + + view.addSubview(tableView) + tableView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor) + ]) + } + + // MARK: - Data + + private func loadLogs() { + logs = logger.getLogs() + tableView.reloadData() + scrollToBottom() + } + + @objc private func handleNewLog(notification: Notification) { + guard let logEntry = notification.object as? LogEntry else { return } + + DispatchQueue.main.async { + self.logs.append(logEntry) + self.tableView.reloadData() + self.scrollToBottom() + } + } + + private func scrollToBottom() { + guard !logs.isEmpty else { return } + let indexPath = IndexPath(row: logs.count - 1, section: 0) + tableView.scrollToRow(at: indexPath, at: .bottom, animated: true) + } + + @objc private func clearLogs() { + logger.clearLogs() + logs.removeAll() + tableView.reloadData() + } + + // MARK: - UITableViewDataSource + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return logs.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "LogCell", for: indexPath) + + let log = logs[indexPath.row] + + // Configure cell + if #available(iOS 14.0, *) { + var content = cell.defaultContentConfiguration() + content.text = log.message + + // Format timestamp + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "HH:mm:ss.SSS" + content.secondaryText = "\(dateFormatter.string(from: log.timestamp)) [\(log.type.rawValue)]" + + // Set text color based on log type + switch log.type { + case .error: + content.textProperties.color = .systemRed + case .warning: + content.textProperties.color = .systemOrange + case .info: + content.textProperties.color = .systemBlue + case .debug: + content.textProperties.color = .systemGray + } + + cell.contentConfiguration = content + } else { + // Fallback for older iOS versions + cell.textLabel?.text = log.message + + // Format timestamp + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "HH:mm:ss.SSS" + cell.detailTextLabel?.text = "\(dateFormatter.string(from: log.timestamp)) [\(log.type.rawValue)]" + + // Set text color based on log type + switch log.type { + case .error: + cell.textLabel?.textColor = .systemRed + case .warning: + cell.textLabel?.textColor = .systemOrange + case .info: + cell.textLabel?.textColor = .systemBlue + case .debug: + cell.textLabel?.textColor = .systemGray + } + } + + return cell + } +} diff --git a/iOS/Debugger/UI/ViewControllers/MemoryViewController.swift b/iOS/Debugger/UI/ViewControllers/MemoryViewController.swift new file mode 100644 index 0000000..baa9b3b --- /dev/null +++ b/iOS/Debugger/UI/ViewControllers/MemoryViewController.swift @@ -0,0 +1,218 @@ +import UIKit + +/// View controller for displaying memory usage +class MemoryViewController: UIViewController { + // MARK: - Properties + + private let debuggerEngine = DebuggerEngine.shared + private let scrollView = UIScrollView() + private let contentView = UIView() + + // Memory stats labels + private let usedMemoryLabel = UILabel() + private let freeMemoryLabel = UILabel() + private let totalMemoryLabel = UILabel() + private let memoryGraphView = UIView() + + // Refresh control + private let refreshControl = UIRefreshControl() + + // Timer for auto-refresh + private var refreshTimer: Timer? + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + setupUI() + setupMemoryViews() + updateMemoryInfo() + + // Start auto-refresh timer + startRefreshTimer() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + // Refresh when view appears + updateMemoryInfo() + startRefreshTimer() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + // Stop timer when view disappears + stopRefreshTimer() + } + + deinit { + stopRefreshTimer() + } + + // MARK: - Setup + + private func setupUI() { + title = "Memory" + view.backgroundColor = .systemBackground + + // Add refresh button + navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .refresh, + target: self, + action: #selector(manualRefresh) + ) + + // Setup scroll view with refresh control + scrollView.refreshControl = refreshControl + refreshControl.addTarget(self, action: #selector(manualRefresh), for: .valueChanged) + + view.addSubview(scrollView) + scrollView.addSubview(contentView) + + scrollView.translatesAutoresizingMaskIntoConstraints = false + contentView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + + contentView.topAnchor.constraint(equalTo: scrollView.topAnchor), + contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), + contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor) + ]) + } + + private func setupMemoryViews() { + // Configure labels + usedMemoryLabel.font = UIFont.systemFont(ofSize: 16) + freeMemoryLabel.font = UIFont.systemFont(ofSize: 16) + totalMemoryLabel.font = UIFont.systemFont(ofSize: 16, weight: .bold) + + // Configure memory graph view + memoryGraphView.backgroundColor = .systemGray6 + memoryGraphView.layer.cornerRadius = 8 + + // Add to content view + contentView.addSubview(usedMemoryLabel) + contentView.addSubview(freeMemoryLabel) + contentView.addSubview(totalMemoryLabel) + contentView.addSubview(memoryGraphView) + + // Configure constraints + usedMemoryLabel.translatesAutoresizingMaskIntoConstraints = false + freeMemoryLabel.translatesAutoresizingMaskIntoConstraints = false + totalMemoryLabel.translatesAutoresizingMaskIntoConstraints = false + memoryGraphView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + totalMemoryLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20), + totalMemoryLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + totalMemoryLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + + usedMemoryLabel.topAnchor.constraint(equalTo: totalMemoryLabel.bottomAnchor, constant: 16), + usedMemoryLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + usedMemoryLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + + freeMemoryLabel.topAnchor.constraint(equalTo: usedMemoryLabel.bottomAnchor, constant: 8), + freeMemoryLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + freeMemoryLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + + memoryGraphView.topAnchor.constraint(equalTo: freeMemoryLabel.bottomAnchor, constant: 20), + memoryGraphView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + memoryGraphView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + memoryGraphView.heightAnchor.constraint(equalToConstant: 40), + memoryGraphView.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -20) + ]) + } + + // MARK: - Memory Info + + private func updateMemoryInfo() { + let memoryInfo = debuggerEngine.getMemoryInfo() + + // Update labels + totalMemoryLabel.text = "Total Memory: \(formatMemorySize(memoryInfo.total))" + usedMemoryLabel.text = "Used Memory: \(formatMemorySize(memoryInfo.used))" + freeMemoryLabel.text = "Free Memory: \(formatMemorySize(memoryInfo.free))" + + // Update graph + updateMemoryGraph(used: memoryInfo.used, total: memoryInfo.total) + + // End refreshing if needed + if refreshControl.isRefreshing { + refreshControl.endRefreshing() + } + } + + private func updateMemoryGraph(used: UInt64, total: UInt64) { + // Remove existing subviews + memoryGraphView.subviews.forEach { $0.removeFromSuperview() } + + // Calculate percentage + let percentage = min(Double(used) / Double(total), 1.0) + + // Create used memory view + let usedView = UIView() + usedView.backgroundColor = percentage > 0.8 ? .systemRed : (percentage > 0.6 ? .systemOrange : .systemBlue) + usedView.layer.cornerRadius = 6 + + memoryGraphView.addSubview(usedView) + usedView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + usedView.leadingAnchor.constraint(equalTo: memoryGraphView.leadingAnchor, constant: 2), + usedView.topAnchor.constraint(equalTo: memoryGraphView.topAnchor, constant: 2), + usedView.bottomAnchor.constraint(equalTo: memoryGraphView.bottomAnchor, constant: -2), + usedView.widthAnchor.constraint(equalTo: memoryGraphView.widthAnchor, multiplier: CGFloat(percentage), constant: -4) + ]) + + // Add percentage label + let percentLabel = UILabel() + percentLabel.text = "\(Int(percentage * 100))%" + percentLabel.textColor = .white + percentLabel.font = UIFont.systemFont(ofSize: 12, weight: .bold) + percentLabel.textAlignment = .center + + usedView.addSubview(percentLabel) + percentLabel.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + percentLabel.centerXAnchor.constraint(equalTo: usedView.centerXAnchor), + percentLabel.centerYAnchor.constraint(equalTo: usedView.centerYAnchor) + ]) + } + + private func formatMemorySize(_ size: UInt64) -> String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useAll] + formatter.countStyle = .memory + return formatter.string(fromByteCount: Int64(size)) + } + + // MARK: - Refresh + + @objc private func manualRefresh() { + updateMemoryInfo() + } + + private func startRefreshTimer() { + stopRefreshTimer() + refreshTimer = Timer.scheduledTimer(timeInterval: 2.0, target: self, selector: #selector(timerRefresh), userInfo: nil, repeats: true) + } + + private func stopRefreshTimer() { + refreshTimer?.invalidate() + refreshTimer = nil + } + + @objc private func timerRefresh() { + updateMemoryInfo() + } +} diff --git a/iOS/Debugger/UI/ViewControllers/NetworkMonitorViewController.swift b/iOS/Debugger/UI/ViewControllers/NetworkMonitorViewController.swift new file mode 100644 index 0000000..0da292e --- /dev/null +++ b/iOS/Debugger/UI/ViewControllers/NetworkMonitorViewController.swift @@ -0,0 +1,340 @@ +import UIKit + +/// View controller for monitoring network activity +class NetworkMonitorViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { + // MARK: - Properties + + private let tableView = UITableView() + private let debuggerEngine = DebuggerEngine.shared + private var networkRequests: [NetworkRequest] = [] + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + setupUI() + setupTableView() + loadNetworkRequests() + + // Register for network request notifications + NotificationCenter.default.addObserver( + self, + selector: #selector(handleNewNetworkRequest), + name: .newNetworkRequest, + object: nil + ) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } + + // MARK: - Setup + + private func setupUI() { + title = "Network Monitor" + view.backgroundColor = .systemBackground + + // Add clear button + navigationItem.rightBarButtonItem = UIBarButtonItem( + title: "Clear", + style: .plain, + target: self, + action: #selector(clearRequests) + ) + } + + private func setupTableView() { + tableView.dataSource = self + tableView.delegate = self + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "NetworkRequestCell") + tableView.rowHeight = UITableView.automaticDimension + tableView.estimatedRowHeight = 80 + + view.addSubview(tableView) + tableView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor) + ]) + } + + // MARK: - Data + + private func loadNetworkRequests() { + networkRequests = debuggerEngine.getNetworkRequests() + tableView.reloadData() + } + + @objc private func handleNewNetworkRequest(notification: Notification) { + guard let request = notification.object as? NetworkRequest else { return } + + DispatchQueue.main.async { + self.networkRequests.insert(request, at: 0) + self.tableView.reloadData() + } + } + + @objc private func clearRequests() { + debuggerEngine.clearNetworkRequests() + networkRequests.removeAll() + tableView.reloadData() + } + + // MARK: - UITableViewDataSource + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return networkRequests.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "NetworkRequestCell", for: indexPath) + + let request = networkRequests[indexPath.row] + + // Configure cell + if #available(iOS 14.0, *) { + var content = cell.defaultContentConfiguration() + + // Set URL as main text + content.text = request.url + + // Set method and status as secondary text + let statusColor: UIColor = (200...299).contains(request.statusCode) ? .systemGreen : .systemRed + content.secondaryText = "\(request.method) - Status: \(request.statusCode)" + content.secondaryTextProperties.color = statusColor + + // Add timestamp + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "HH:mm:ss" + let timeString = dateFormatter.string(from: request.timestamp) + content.secondaryText! += " - \(timeString)" + + cell.contentConfiguration = content + } else { + // Fallback for older iOS versions + cell.textLabel?.text = request.url + + // Set method and status as detail text + let statusColor: UIColor = (200...299).contains(request.statusCode) ? .systemGreen : .systemRed + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "HH:mm:ss" + let timeString = dateFormatter.string(from: request.timestamp) + + cell.detailTextLabel?.text = "\(request.method) - Status: \(request.statusCode) - \(timeString)" + cell.detailTextLabel?.textColor = statusColor + } + + return cell + } + + // MARK: - UITableViewDelegate + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + + let request = networkRequests[indexPath.row] + showRequestDetails(request) + } + + private func showRequestDetails(_ request: NetworkRequest) { + let detailVC = NetworkRequestDetailViewController(request: request) + navigationController?.pushViewController(detailVC, animated: true) + } +} + +/// View controller for displaying network request details +class NetworkRequestDetailViewController: UIViewController { + // MARK: - Properties + + private let scrollView = UIScrollView() + private let contentView = UIView() + + private let urlLabel = UILabel() + private let methodLabel = UILabel() + private let statusLabel = UILabel() + private let timestampLabel = UILabel() + private let requestHeadersLabel = UILabel() + private let requestBodyLabel = UILabel() + private let responseHeadersLabel = UILabel() + private let responseBodyLabel = UILabel() + + private let request: NetworkRequest + + // MARK: - Initialization + + init(request: NetworkRequest) { + self.request = request + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + setupUI() + setupLabels() + populateData() + } + + // MARK: - Setup + + private func setupUI() { + title = "Request Details" + view.backgroundColor = .systemBackground + + // Setup scroll view + view.addSubview(scrollView) + scrollView.addSubview(contentView) + + scrollView.translatesAutoresizingMaskIntoConstraints = false + contentView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + + contentView.topAnchor.constraint(equalTo: scrollView.topAnchor), + contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), + contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor) + ]) + } + + private func setupLabels() { + // Configure labels + let labels = [urlLabel, methodLabel, statusLabel, timestampLabel, + requestHeadersLabel, requestBodyLabel, responseHeadersLabel, responseBodyLabel] + + for label in labels { + label.numberOfLines = 0 + label.font = UIFont.systemFont(ofSize: 14) + contentView.addSubview(label) + label.translatesAutoresizingMaskIntoConstraints = false + } + + // Set section titles + let titleFont = UIFont.boldSystemFont(ofSize: 16) + + let requestHeadersTitle = UILabel() + requestHeadersTitle.text = "Request Headers" + requestHeadersTitle.font = titleFont + + let requestBodyTitle = UILabel() + requestBodyTitle.text = "Request Body" + requestBodyTitle.font = titleFont + + let responseHeadersTitle = UILabel() + responseHeadersTitle.text = "Response Headers" + responseHeadersTitle.font = titleFont + + let responseBodyTitle = UILabel() + responseBodyTitle.text = "Response Body" + responseBodyTitle.font = titleFont + + let titles = [requestHeadersTitle, requestBodyTitle, responseHeadersTitle, responseBodyTitle] + + for title in titles { + contentView.addSubview(title) + title.translatesAutoresizingMaskIntoConstraints = false + } + + // Layout constraints + NSLayoutConstraint.activate([ + urlLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20), + urlLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + urlLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + + methodLabel.topAnchor.constraint(equalTo: urlLabel.bottomAnchor, constant: 10), + methodLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + methodLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + + statusLabel.topAnchor.constraint(equalTo: methodLabel.bottomAnchor, constant: 10), + statusLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + statusLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + + timestampLabel.topAnchor.constraint(equalTo: statusLabel.bottomAnchor, constant: 10), + timestampLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + timestampLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + + requestHeadersTitle.topAnchor.constraint(equalTo: timestampLabel.bottomAnchor, constant: 20), + requestHeadersTitle.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + requestHeadersTitle.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + + requestHeadersLabel.topAnchor.constraint(equalTo: requestHeadersTitle.bottomAnchor, constant: 10), + requestHeadersLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + requestHeadersLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + + requestBodyTitle.topAnchor.constraint(equalTo: requestHeadersLabel.bottomAnchor, constant: 20), + requestBodyTitle.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + requestBodyTitle.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + + requestBodyLabel.topAnchor.constraint(equalTo: requestBodyTitle.bottomAnchor, constant: 10), + requestBodyLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + requestBodyLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + + responseHeadersTitle.topAnchor.constraint(equalTo: requestBodyLabel.bottomAnchor, constant: 20), + responseHeadersTitle.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + responseHeadersTitle.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + + responseHeadersLabel.topAnchor.constraint(equalTo: responseHeadersTitle.bottomAnchor, constant: 10), + responseHeadersLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + responseHeadersLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + + responseBodyTitle.topAnchor.constraint(equalTo: responseHeadersLabel.bottomAnchor, constant: 20), + responseBodyTitle.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + responseBodyTitle.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + + responseBodyLabel.topAnchor.constraint(equalTo: responseBodyTitle.bottomAnchor, constant: 10), + responseBodyLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + responseBodyLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + responseBodyLabel.bottomAnchor.constraint(equalTo: contentView.bottomAnchor, constant: -20) + ]) + } + + private func populateData() { + // Format timestamp + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .medium + dateFormatter.timeStyle = .medium + let timeString = dateFormatter.string(from: request.timestamp) + + // Set basic info + urlLabel.text = "URL: \(request.url)" + methodLabel.text = "Method: \(request.method)" + + // Set status with color + let statusColor: UIColor = (200...299).contains(request.statusCode) ? .systemGreen : .systemRed + statusLabel.text = "Status: \(request.statusCode)" + statusLabel.textColor = statusColor + + timestampLabel.text = "Time: \(timeString)" + + // Set headers and body + requestHeadersLabel.text = formatDictionary(request.requestHeaders) + requestBodyLabel.text = request.requestBody.isEmpty ? "No body" : request.requestBody + responseHeadersLabel.text = formatDictionary(request.responseHeaders) + responseBodyLabel.text = request.responseBody.isEmpty ? "No body" : request.responseBody + } + + private func formatDictionary(_ dict: [String: String]) -> String { + if dict.isEmpty { + return "No headers" + } + + return dict.map { "\($0.key): \($0.value)" }.joined(separator: "\n") + } +} diff --git a/iOS/Debugger/UI/ViewControllers/PerformanceViewController.swift b/iOS/Debugger/UI/ViewControllers/PerformanceViewController.swift new file mode 100644 index 0000000..a5c9c22 --- /dev/null +++ b/iOS/Debugger/UI/ViewControllers/PerformanceViewController.swift @@ -0,0 +1,344 @@ +import UIKit + +/// View controller for monitoring app performance +class PerformanceViewController: UIViewController { + // MARK: - Properties + + private let debuggerEngine = DebuggerEngine.shared + private let scrollView = UIScrollView() + private let contentView = UIView() + + // Performance stats labels + private let cpuUsageLabel = UILabel() + private let memoryUsageLabel = UILabel() + private let fpsLabel = UILabel() + private let diskUsageLabel = UILabel() + private let batteryUsageLabel = UILabel() + + // Performance graphs + private let cpuGraphView = UIView() + private let memoryGraphView = UIView() + private let fpsGraphView = UIView() + + // Refresh control + private let refreshControl = UIRefreshControl() + + // Timer for auto-refresh + private var refreshTimer: Timer? + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + setupUI() + setupPerformanceViews() + updatePerformanceInfo() + + // Start auto-refresh timer + startRefreshTimer() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + // Refresh when view appears + updatePerformanceInfo() + startRefreshTimer() + } + + override func viewWillDisappear(_ animated: Bool) { + super.viewWillDisappear(animated) + + // Stop timer when view disappears + stopRefreshTimer() + } + + deinit { + stopRefreshTimer() + } + + // MARK: - Setup + + private func setupUI() { + title = "Performance" + view.backgroundColor = .systemBackground + + // Add refresh button + navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .refresh, + target: self, + action: #selector(manualRefresh) + ) + + // Setup scroll view with refresh control + scrollView.refreshControl = refreshControl + refreshControl.addTarget(self, action: #selector(manualRefresh), for: .valueChanged) + + view.addSubview(scrollView) + scrollView.addSubview(contentView) + + scrollView.translatesAutoresizingMaskIntoConstraints = false + contentView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + scrollView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + scrollView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + + contentView.topAnchor.constraint(equalTo: scrollView.topAnchor), + contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), + contentView.bottomAnchor.constraint(equalTo: scrollView.bottomAnchor), + contentView.widthAnchor.constraint(equalTo: scrollView.widthAnchor) + ]) + } + + private func setupPerformanceViews() { + // Configure labels + let labels = [cpuUsageLabel, memoryUsageLabel, fpsLabel, diskUsageLabel, batteryUsageLabel] + + for label in labels { + label.font = UIFont.systemFont(ofSize: 16) + contentView.addSubview(label) + label.translatesAutoresizingMaskIntoConstraints = false + } + + // Configure graph views + let graphViews = [cpuGraphView, memoryGraphView, fpsGraphView] + + for graphView in graphViews { + graphView.backgroundColor = .systemGray6 + graphView.layer.cornerRadius = 8 + contentView.addSubview(graphView) + graphView.translatesAutoresizingMaskIntoConstraints = false + } + + // Configure constraints + NSLayoutConstraint.activate([ + cpuUsageLabel.topAnchor.constraint(equalTo: contentView.topAnchor, constant: 20), + cpuUsageLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + cpuUsageLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + + cpuGraphView.topAnchor.constraint(equalTo: cpuUsageLabel.bottomAnchor, constant: 8), + cpuGraphView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + cpuGraphView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + cpuGraphView.heightAnchor.constraint(equalToConstant: 40), + + memoryUsageLabel.topAnchor.constraint(equalTo: cpuGraphView.bottomAnchor, constant: 20), + memoryUsageLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + memoryUsageLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + + memoryGraphView.topAnchor.constraint(equalTo: memoryUsageLabel.bottomAnchor, constant: 8), + memoryGraphView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + memoryGraphView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + memoryGraphView.heightAnchor.constraint(equalToConstant: 40), + + fpsLabel.topAnchor.constraint(equalTo: memoryGraphView.bottomAnchor, constant: 20), + fpsLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + fpsLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + + fpsGraphView.topAnchor.constraint(equalTo: fpsLabel.bottomAnchor, constant: 8), + fpsGraphView.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + fpsGraphView.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + fpsGraphView.heightAnchor.constraint(equalToConstant: 40), + + diskUsageLabel.topAnchor.constraint(equalTo: fpsGraphView.bottomAnchor, constant: 20), + diskUsageLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + diskUsageLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + + batteryUsageLabel.topAnchor.constraint(equalTo: diskUsageLabel.bottomAnchor, constant: 20), + batteryUsageLabel.leadingAnchor.constraint(equalTo: contentView.leadingAnchor, constant: 20), + batteryUsageLabel.trailingAnchor.constraint(equalTo: contentView.trailingAnchor, constant: -20), + batteryUsageLabel.bottomAnchor.constraint(lessThanOrEqualTo: contentView.bottomAnchor, constant: -20) + ]) + } + + // MARK: - Performance Info + + private func updatePerformanceInfo() { + let performanceInfo = debuggerEngine.getPerformanceInfo() + + // Update labels + cpuUsageLabel.text = "CPU Usage: \(String(format: "%.1f%%", performanceInfo.cpuUsage))" + memoryUsageLabel.text = "Memory Usage: \(formatMemorySize(performanceInfo.memoryUsage))" + fpsLabel.text = "FPS: \(String(format: "%.1f", performanceInfo.fps))" + diskUsageLabel.text = "Disk Usage: \(formatMemorySize(performanceInfo.diskUsage)) / \(formatMemorySize(performanceInfo.diskTotal))" + + // Battery info + let batteryLevel = UIDevice.current.batteryLevel + let batteryState = UIDevice.current.batteryState + + var batteryStateString = "Unknown" + switch batteryState { + case .charging: + batteryStateString = "Charging" + case .full: + batteryStateString = "Full" + case .unplugged: + batteryStateString = "Unplugged" + case .unknown: + batteryStateString = "Unknown" + @unknown default: + batteryStateString = "Unknown" + } + + if batteryLevel < 0 { + batteryUsageLabel.text = "Battery: \(batteryStateString)" + } else { + batteryUsageLabel.text = "Battery: \(Int(batteryLevel * 100))% (\(batteryStateString))" + } + + // Update graphs + updateCPUGraph(cpuUsage: performanceInfo.cpuUsage) + updateMemoryGraph(memoryUsage: performanceInfo.memoryUsage, memoryTotal: performanceInfo.memoryTotal) + updateFPSGraph(fps: performanceInfo.fps) + + // End refreshing if needed + if refreshControl.isRefreshing { + refreshControl.endRefreshing() + } + } + + private func updateCPUGraph(cpuUsage: Double) { + // Remove existing subviews + cpuGraphView.subviews.forEach { $0.removeFromSuperview() } + + // Calculate percentage + let percentage = min(cpuUsage / 100.0, 1.0) + + // Create used CPU view + let usedView = UIView() + usedView.backgroundColor = percentage > 0.8 ? .systemRed : (percentage > 0.5 ? .systemOrange : .systemBlue) + usedView.layer.cornerRadius = 6 + + cpuGraphView.addSubview(usedView) + usedView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + usedView.leadingAnchor.constraint(equalTo: cpuGraphView.leadingAnchor, constant: 2), + usedView.topAnchor.constraint(equalTo: cpuGraphView.topAnchor, constant: 2), + usedView.bottomAnchor.constraint(equalTo: cpuGraphView.bottomAnchor, constant: -2), + usedView.widthAnchor.constraint(equalTo: cpuGraphView.widthAnchor, multiplier: CGFloat(percentage), constant: -4) + ]) + + // Add percentage label + let percentLabel = UILabel() + percentLabel.text = "\(Int(percentage * 100))%" + percentLabel.textColor = .white + percentLabel.font = UIFont.systemFont(ofSize: 12, weight: .bold) + percentLabel.textAlignment = .center + + usedView.addSubview(percentLabel) + percentLabel.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + percentLabel.centerXAnchor.constraint(equalTo: usedView.centerXAnchor), + percentLabel.centerYAnchor.constraint(equalTo: usedView.centerYAnchor) + ]) + } + + private func updateMemoryGraph(memoryUsage: UInt64, memoryTotal: UInt64) { + // Remove existing subviews + memoryGraphView.subviews.forEach { $0.removeFromSuperview() } + + // Calculate percentage + let percentage = min(Double(memoryUsage) / Double(memoryTotal), 1.0) + + // Create used memory view + let usedView = UIView() + usedView.backgroundColor = percentage > 0.8 ? .systemRed : (percentage > 0.6 ? .systemOrange : .systemBlue) + usedView.layer.cornerRadius = 6 + + memoryGraphView.addSubview(usedView) + usedView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + usedView.leadingAnchor.constraint(equalTo: memoryGraphView.leadingAnchor, constant: 2), + usedView.topAnchor.constraint(equalTo: memoryGraphView.topAnchor, constant: 2), + usedView.bottomAnchor.constraint(equalTo: memoryGraphView.bottomAnchor, constant: -2), + usedView.widthAnchor.constraint(equalTo: memoryGraphView.widthAnchor, multiplier: CGFloat(percentage), constant: -4) + ]) + + // Add percentage label + let percentLabel = UILabel() + percentLabel.text = "\(Int(percentage * 100))%" + percentLabel.textColor = .white + percentLabel.font = UIFont.systemFont(ofSize: 12, weight: .bold) + percentLabel.textAlignment = .center + + usedView.addSubview(percentLabel) + percentLabel.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + percentLabel.centerXAnchor.constraint(equalTo: usedView.centerXAnchor), + percentLabel.centerYAnchor.constraint(equalTo: usedView.centerYAnchor) + ]) + } + + private func updateFPSGraph(fps: Double) { + // Remove existing subviews + fpsGraphView.subviews.forEach { $0.removeFromSuperview() } + + // Calculate percentage (60 FPS is considered 100%) + let percentage = min(fps / 60.0, 1.0) + + // Create FPS view + let fpsView = UIView() + fpsView.backgroundColor = percentage < 0.5 ? .systemRed : (percentage < 0.8 ? .systemOrange : .systemGreen) + fpsView.layer.cornerRadius = 6 + + fpsGraphView.addSubview(fpsView) + fpsView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + fpsView.leadingAnchor.constraint(equalTo: fpsGraphView.leadingAnchor, constant: 2), + fpsView.topAnchor.constraint(equalTo: fpsGraphView.topAnchor, constant: 2), + fpsView.bottomAnchor.constraint(equalTo: fpsGraphView.bottomAnchor, constant: -2), + fpsView.widthAnchor.constraint(equalTo: fpsGraphView.widthAnchor, multiplier: CGFloat(percentage), constant: -4) + ]) + + // Add FPS label + let fpsValueLabel = UILabel() + fpsValueLabel.text = "\(Int(fps)) FPS" + fpsValueLabel.textColor = .white + fpsValueLabel.font = UIFont.systemFont(ofSize: 12, weight: .bold) + fpsValueLabel.textAlignment = .center + + fpsView.addSubview(fpsValueLabel) + fpsValueLabel.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + fpsValueLabel.centerXAnchor.constraint(equalTo: fpsView.centerXAnchor), + fpsValueLabel.centerYAnchor.constraint(equalTo: fpsView.centerYAnchor) + ]) + } + + private func formatMemorySize(_ size: UInt64) -> String { + let formatter = ByteCountFormatter() + formatter.allowedUnits = [.useAll] + formatter.countStyle = .memory + return formatter.string(fromByteCount: Int64(size)) + } + + // MARK: - Refresh + + @objc private func manualRefresh() { + updatePerformanceInfo() + } + + private func startRefreshTimer() { + stopRefreshTimer() + refreshTimer = Timer.scheduledTimer(timeInterval: 1.0, target: self, selector: #selector(timerRefresh), userInfo: nil, repeats: true) + } + + private func stopRefreshTimer() { + refreshTimer?.invalidate() + refreshTimer = nil + } + + @objc private func timerRefresh() { + updatePerformanceInfo() + } +} diff --git a/iOS/Debugger/UI/ViewControllers/VariablesViewController.swift b/iOS/Debugger/UI/ViewControllers/VariablesViewController.swift new file mode 100644 index 0000000..73761e0 --- /dev/null +++ b/iOS/Debugger/UI/ViewControllers/VariablesViewController.swift @@ -0,0 +1,113 @@ +import UIKit + +/// View controller for displaying variables +class VariablesViewController: UIViewController, UITableViewDataSource, UITableViewDelegate { + // MARK: - Properties + + private let tableView = UITableView() + private let debuggerEngine = DebuggerEngine.shared + private var variables: [Variable] = [] + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + + setupUI() + setupTableView() + loadVariables() + } + + override func viewWillAppear(_ animated: Bool) { + super.viewWillAppear(animated) + + // Refresh variables when view appears + loadVariables() + } + + // MARK: - Setup + + private func setupUI() { + title = "Variables" + view.backgroundColor = .systemBackground + + // Add refresh button + navigationItem.rightBarButtonItem = UIBarButtonItem( + barButtonSystemItem: .refresh, + target: self, + action: #selector(refreshVariables) + ) + } + + private func setupTableView() { + tableView.dataSource = self + tableView.delegate = self + tableView.register(UITableViewCell.self, forCellReuseIdentifier: "VariableCell") + + view.addSubview(tableView) + tableView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor) + ]) + } + + // MARK: - Data + + private func loadVariables() { + variables = debuggerEngine.getVariables() + tableView.reloadData() + } + + @objc private func refreshVariables() { + loadVariables() + } + + // MARK: - UITableViewDataSource + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return variables.count + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "VariableCell", for: indexPath) + + let variable = variables[indexPath.row] + + // Configure cell + if #available(iOS 14.0, *) { + var content = cell.defaultContentConfiguration() + content.text = variable.name + content.secondaryText = "\(variable.type): \(variable.value)" + cell.contentConfiguration = content + } else { + // Fallback for older iOS versions + cell.textLabel?.text = variable.name + cell.detailTextLabel?.text = "\(variable.type): \(variable.value)" + } + + return cell + } + + // MARK: - UITableViewDelegate + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + + let variable = variables[indexPath.row] + + // Show variable details + let alert = UIAlertController( + title: variable.name, + message: "Type: \(variable.type)\nValue: \(variable.value)\nAddress: \(variable.address)", + preferredStyle: .alert + ) + + alert.addAction(UIAlertAction(title: "OK", style: .default)) + + present(alert, animated: true) + } +} diff --git a/iOS/Views/AI b/iOS/Views/AI index 2bdb6f3..bb26c70 100644 --- a/iOS/Views/AI +++ b/iOS/Views/AI @@ -1,936 +1,382 @@ import UIKit -class ChatViewController: UIViewController, UITableViewDataSource, UITableViewDelegate, - UISheetPresentationControllerDelegate -{ +/// View controller for the AI Assistant tab +class AIViewController: UIViewController { + // MARK: - UI Components - - private let tableView = UITableView() - private let inputContainer = UIView() - private let textField = UITextField() - private let sendButton = UIButton(type: .system) - private let activityIndicator = UIActivityIndicatorView(style: .medium) - + + private let welcomeLabel = UILabel() + private let startChatButton = UIButton(type: .system) + private let recentChatsTableView = UITableView(frame: .zero, style: .insetGrouped) + private let emptyStateView = UIView() + // MARK: - Data - - public var currentSession: ChatSession // Public to allow access from FloatingButtonManager - private var messages: [ChatMessage] = [] - - // Thread-safe state management - private let stateQueue = DispatchQueue(label: "com.backdoor.chatViewControllerState", qos: .userInteractive) - private var _isProcessingMessage = false - private var isProcessingMessage: Bool { - get { stateQueue.sync { _isProcessingMessage } } - set { - stateQueue.sync { _isProcessingMessage = newValue } - - // Update UI based on processing state - DispatchQueue.main.async { [weak self] in - if let self = self { - self.updateProcessingState(isProcessing: newValue) - } - } - } - } - - // Animation view for sending state - private var sendingAnimation: UIImageView? - - // MARK: - Callbacks - - /// Called when the view controller is dismissed - used by FloatingButtonManager to reset state - var dismissHandler: (() -> Void)? - - // MARK: - Initialization - - init(session: ChatSession? = nil) { - if let session = session { - currentSession = session - } else { - // Create a new session with current date/time - let title = "Chat on \(DateFormatter.localizedString(from: Date(), dateStyle: .medium, timeStyle: .short))" - do { - currentSession = try CoreDataManager.shared.createAIChatSession(title: title) - } catch { - Debug.shared.log(message: "Failed to create chat session: \(error)", type: .error) - // Fallback with empty session - this is a safety measure - currentSession = ChatSession() - } - } - super.init(nibName: nil, bundle: nil) - } - - @available(*, unavailable) - required init?(coder _: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - + + private var recentSessions: [ChatSession] = [] + // MARK: - Lifecycle - + override func viewDidLoad() { super.viewDidLoad() + setupUI() - loadMessages() - - // Register for app background/foreground notifications - setupAppStateObservers() - - // Add a welcome message if this is a new session - if messages.isEmpty { - addWelcomeMessage() - } + configureNavigationBar() + setupTableView() + setupEmptyState() + + // Log initialization + Debug.shared.log(message: "AIViewController initialized", type: .info) } - + override func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) - - // Set navigation bar appearance - if let navigationController = navigationController { - navigationController.navigationBar.prefersLargeTitles = false - navigationController.navigationBar.isTranslucent = true - } + + // Load recent chat sessions + loadRecentSessions() } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - - // If we're being dismissed (not just covered by another VC), call the dismissHandler - if isBeingDismissed || navigationController?.isBeingDismissed == true { - dismissHandler?() - } - } - - deinit { - // Clean up notification observers to prevent memory leaks - NotificationCenter.default.removeObserver(self) - Debug.shared.log(message: "ChatViewController deinit", type: .debug) - } - - // MARK: - Welcome Message - - private func addWelcomeMessage() { - do { - // Add a welcome message from the AI - let welcomeMessage = try CoreDataManager.shared.addMessage( - to: currentSession, - sender: "ai", - content: "Hello! I'm your Backdoor assistant. I can help you sign apps, manage sources, and navigate through the app. How can I assist you today?" - ) - messages.append(welcomeMessage) - tableView.reloadData() - scrollToBottom(animated: false) - } catch { - Debug.shared.log(message: "Failed to add welcome message: \(error)", type: .error) - } - } - - // MARK: - App State Handling - - private func setupAppStateObservers() { - NotificationCenter.default.addObserver( - self, - selector: #selector(appDidEnterBackground), - name: UIApplication.didEnterBackgroundNotification, - object: nil - ) - - NotificationCenter.default.addObserver( - self, - selector: #selector(appWillEnterForeground), - name: UIApplication.willEnterForegroundNotification, - object: nil - ) - } - - @objc private func appDidEnterBackground() { - // Save any pending state when app goes to background - Debug.shared.log(message: "Chat view controller saving state before background", type: .debug) - - // Cancel any ongoing message processing - if isProcessingMessage { - // We'll let the ongoing process complete but ensure UI is updated on return - Debug.shared.log(message: "App entering background while processing message", type: .debug) - } - } - - @objc private func appWillEnterForeground() { - // Refresh data when app comes to foreground - Debug.shared.log(message: "Chat view controller becoming active after background", type: .debug) - - // Refresh messages to ensure we're in sync with CoreData - DispatchQueue.main.async { [weak self] in - self?.loadMessages() - - // Re-enable UI if it was left in a processing state - if let self = self, self.isProcessingMessage { - self.activityIndicator.stopAnimating() - self.sendButton.isEnabled = true - self.textField.isEnabled = true - self.isProcessingMessage = false - } - } - } - + // MARK: - UI Setup - + private func setupUI() { view.backgroundColor = .systemBackground - - // Navigation bar - navigationItem.title = currentSession.title - navigationItem.leftBarButtonItem = UIBarButtonItem( - image: UIImage(systemName: "clock.arrow.circlepath"), - style: .plain, - target: self, - action: #selector(showHistory) - ) - navigationItem.rightBarButtonItem = UIBarButtonItem( - image: UIImage(systemName: "plus.bubble"), - style: .plain, - target: self, - action: #selector(newChat) - ) - - // Table view for messages - setupTableView() - - // Input controls - setupInputControls() - - // Layout constraints - setupConstraints() - - // Keyboard handling - setupKeyboardHandling() - } - - private func setupTableView() { - tableView.dataSource = self - tableView.delegate = self - - // Register cell types - tableView.register(UserMessageCell.self, forCellReuseIdentifier: "UserCell") - tableView.register(AIMessageCell.self, forCellReuseIdentifier: "AICell") - tableView.register(SystemMessageCell.self, forCellReuseIdentifier: "SystemCell") - - // Configure tableView appearance - tableView.rowHeight = UITableView.automaticDimension - tableView.estimatedRowHeight = 60 - tableView.backgroundColor = .systemGray6 - tableView.separatorStyle = .none - tableView.keyboardDismissMode = .interactive - - // Add pull-to-refresh support - let refreshControl = UIRefreshControl() - refreshControl.attributedTitle = NSAttributedString(string: "Pull to refresh") - refreshControl.addTarget(self, action: #selector(refreshMessages), for: .valueChanged) - tableView.refreshControl = refreshControl - - // iOS 15+ specific customization - if #available(iOS 15.0, *) { - tableView.sectionHeaderTopPadding = 0 - } - - // Setup empty state view for when no messages exist - setupEmptyStateView() - - view.addSubview(tableView) - } - - private func setupEmptyStateView() { - // Create an empty state view - let emptyView = UIView() - emptyView.isHidden = true - emptyView.backgroundColor = .clear - - // Add animation for empty state using SF Symbol - let animationView = emptyView.addAnimatedIcon( - systemName: "ellipsis.bubble", - tintColor: .systemBlue, - size: CGSize(width: 100, height: 50) - ) - - // Create welcome label - let welcomeLabel = UILabel() - welcomeLabel - .text = "Welcome to the AI Assistant. Ask me anything about app signing, sources, or using Backdoor." + + // Welcome label + welcomeLabel.text = "AI Assistant" + welcomeLabel.font = UIFont.systemFont(ofSize: 24, weight: .bold) welcomeLabel.textAlignment = .center - welcomeLabel.textColor = .secondaryLabel - welcomeLabel.font = .systemFont(ofSize: 16) - welcomeLabel.numberOfLines = 0 - emptyView.addSubview(welcomeLabel) - - // Setup constraints - animationView.translatesAutoresizingMaskIntoConstraints = false + welcomeLabel.textColor = .label + + // Start chat button + startChatButton.setTitle("Start New Chat", for: .normal) + startChatButton.titleLabel?.font = UIFont.systemFont(ofSize: 16, weight: .semibold) + startChatButton.backgroundColor = Preferences.appTintColor.uiColor + startChatButton.setTitleColor(.white, for: .normal) + startChatButton.layer.cornerRadius = 12 + startChatButton.addTarget(self, action: #selector(startNewChat), for: .touchUpInside) + + // Add shadow to button + startChatButton.layer.shadowColor = UIColor.black.cgColor + startChatButton.layer.shadowOpacity = 0.2 + startChatButton.layer.shadowOffset = CGSize(width: 0, height: 2) + startChatButton.layer.shadowRadius = 4 + + // Add subviews + view.addSubview(welcomeLabel) + view.addSubview(startChatButton) + view.addSubview(recentChatsTableView) + view.addSubview(emptyStateView) + + // Configure constraints welcomeLabel.translatesAutoresizingMaskIntoConstraints = false - + startChatButton.translatesAutoresizingMaskIntoConstraints = false + recentChatsTableView.translatesAutoresizingMaskIntoConstraints = false + emptyStateView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ - animationView.centerXAnchor.constraint(equalTo: emptyView.centerXAnchor), - animationView.topAnchor.constraint(equalTo: emptyView.topAnchor), - animationView.widthAnchor.constraint(equalToConstant: 100), - animationView.heightAnchor.constraint(equalToConstant: 50), - - welcomeLabel.topAnchor.constraint(equalTo: animationView.bottomAnchor, constant: 16), - welcomeLabel.centerXAnchor.constraint(equalTo: emptyView.centerXAnchor), - welcomeLabel.leadingAnchor.constraint(equalTo: emptyView.leadingAnchor, constant: 30), - welcomeLabel.trailingAnchor.constraint(equalTo: emptyView.trailingAnchor, constant: -30), + welcomeLabel.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor, constant: 20), + welcomeLabel.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + welcomeLabel.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + + startChatButton.topAnchor.constraint(equalTo: welcomeLabel.bottomAnchor, constant: 20), + startChatButton.leadingAnchor.constraint(equalTo: view.leadingAnchor, constant: 20), + startChatButton.trailingAnchor.constraint(equalTo: view.trailingAnchor, constant: -20), + startChatButton.heightAnchor.constraint(equalToConstant: 50), + + recentChatsTableView.topAnchor.constraint(equalTo: startChatButton.bottomAnchor, constant: 20), + recentChatsTableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + recentChatsTableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + recentChatsTableView.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor), + + emptyStateView.centerXAnchor.constraint(equalTo: recentChatsTableView.centerXAnchor), + emptyStateView.centerYAnchor.constraint(equalTo: recentChatsTableView.centerYAnchor), + emptyStateView.widthAnchor.constraint(equalTo: recentChatsTableView.widthAnchor, multiplier: 0.8), + emptyStateView.heightAnchor.constraint(equalToConstant: 200) ]) - - // Add to table view - tableView.backgroundView = emptyView - } - - @objc private func refreshMessages() { - // Reload messages from database - loadMessages() - - // End refreshing - tableView.refreshControl?.endRefreshing() } - - // Update UI when message processing state changes - private func updateProcessingState(isProcessing: Bool) { - if isProcessing { - // Hide send button, show animation - sendButton.isHidden = true - sendingAnimation?.isHidden = false - - // Start animation manually since UIImageView doesn't have play() - UIView.animate(withDuration: 1.5, delay: 0, options: [.autoreverse, .repeat, .curveEaseInOut], animations: { - self.sendingAnimation?.transform = CGAffineTransform(scaleX: 1.2, y: 1.2).rotated(by: .pi) - }) - } else { - // Hide animation, show send button - if let imageView = sendingAnimation { - // Stop animation manually since UIImageView doesn't have stop() - imageView.layer.removeAllAnimations() - imageView.transform = .identity - } - sendingAnimation?.isHidden = true - sendButton.isHidden = false - } - } - - private func setupInputControls() { - // Input container with enhanced styling - inputContainer.backgroundColor = .systemBackground - inputContainer.applyCardStyling( - cornerRadius: 12, - shadowOpacity: 0.15, - backgroundColor: .systemBackground - ) - view.addSubview(inputContainer) - - // Text field with improved styling - textField.placeholder = "Ask me anything..." - textField.borderStyle = .none - textField.backgroundColor = UIColor.systemGray6 - textField.layer.cornerRadius = 8 - textField.leftView = UIView(frame: CGRect(x: 0, y: 0, width: 10, height: 0)) - textField.leftViewMode = .always - textField.returnKeyType = .send - textField.delegate = self - textField.autocorrectionType = .default - textField.spellCheckingType = .default - textField.enablesReturnKeyAutomatically = true - textField.layer.masksToBounds = true - inputContainer.addSubview(textField) - - // Create animation container for the sending animation - let animationContainer = UIView() - animationContainer.backgroundColor = .clear - inputContainer.addSubview(animationContainer) - - // Send button with enhanced styling - sendButton.setImage(UIImage(systemName: "paperplane.fill"), for: .normal) - sendButton.tintColor = .systemBlue - sendButton.backgroundColor = .clear - sendButton.addTarget(self, action: #selector(sendMessage), for: .touchUpInside) - inputContainer.addSubview(sendButton) - - // Add gradient to sendButton for nicer appearance - sendButton.convertToGradientButton( - colors: [ - UIColor.systemBlue, - UIColor(red: 0.1, green: 0.6, blue: 1.0, alpha: 1.0), - ] - ) - - // Activity indicator (keeping for compatibility) - activityIndicator.color = .systemGray - activityIndicator.hidesWhenStopped = true - inputContainer.addSubview(activityIndicator) - - // Setup constraints with native AutoLayout - textField.translatesAutoresizingMaskIntoConstraints = false - sendButton.translatesAutoresizingMaskIntoConstraints = false - animationContainer.translatesAutoresizingMaskIntoConstraints = false - activityIndicator.translatesAutoresizingMaskIntoConstraints = false - - NSLayoutConstraint.activate([ - textField.leadingAnchor.constraint(equalTo: inputContainer.leadingAnchor, constant: 12), - textField.centerYAnchor.constraint(equalTo: inputContainer.centerYAnchor), - textField.trailingAnchor.constraint(equalTo: sendButton.leadingAnchor, constant: -12), - textField.heightAnchor.constraint(equalToConstant: 36), - - sendButton.trailingAnchor.constraint(equalTo: inputContainer.trailingAnchor, constant: -12), - sendButton.centerYAnchor.constraint(equalTo: inputContainer.centerYAnchor), - sendButton.widthAnchor.constraint(equalToConstant: 40), - sendButton.heightAnchor.constraint(equalToConstant: 40), - - animationContainer.centerXAnchor.constraint(equalTo: sendButton.centerXAnchor), - animationContainer.centerYAnchor.constraint(equalTo: sendButton.centerYAnchor), - animationContainer.widthAnchor.constraint(equalToConstant: 40), - animationContainer.heightAnchor.constraint(equalToConstant: 40), - - activityIndicator.centerXAnchor.constraint(equalTo: sendButton.centerXAnchor), - activityIndicator.centerYAnchor.constraint(equalTo: sendButton.centerYAnchor), - activityIndicator.widthAnchor.constraint(equalToConstant: 20), - activityIndicator.heightAnchor.constraint(equalToConstant: 20), - ]) - - // Add animated icon for sending state - sendingAnimation = animationContainer.addAnimatedIcon( - systemName: "arrow.clockwise", - tintColor: .systemBlue, - size: CGSize(width: 40, height: 40) - ) - sendingAnimation?.isHidden = true + + private func configureNavigationBar() { + navigationItem.title = "AI Assistant" + navigationController?.navigationBar.prefersLargeTitles = true } - - private func setupConstraints() { - // Enable autolayout - tableView.translatesAutoresizingMaskIntoConstraints = false - inputContainer.translatesAutoresizingMaskIntoConstraints = false - textField.translatesAutoresizingMaskIntoConstraints = false - sendButton.translatesAutoresizingMaskIntoConstraints = false - activityIndicator.translatesAutoresizingMaskIntoConstraints = false - - // Safe area guide for proper layout - let safeArea = view.safeAreaLayoutGuide - - // Apply constraints + + private func setupTableView() { + recentChatsTableView.dataSource = self + recentChatsTableView.delegate = self + recentChatsTableView.register(UITableViewCell.self, forCellReuseIdentifier: "ChatSessionCell") + recentChatsTableView.backgroundColor = UIColor.systemGroupedBackground + recentChatsTableView.separatorStyle = UITableViewCell.SeparatorStyle.singleLine + recentChatsTableView.tableFooterView = UIView() + } + + private func setupEmptyState() { + // Create empty state view + let imageView = UIImageView(image: UIImage(systemName: "bubble.left.and.bubble.right")) + imageView.tintColor = .systemGray3 + imageView.contentMode = .scaleAspectFit + + let label = UILabel() + label.text = "No recent chats" + label.textAlignment = .center + label.font = UIFont.systemFont(ofSize: 16, weight: .medium) + label.textColor = .secondaryLabel + + emptyStateView.addSubview(imageView) + emptyStateView.addSubview(label) + + imageView.translatesAutoresizingMaskIntoConstraints = false + label.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ - // Table view - tableView.topAnchor.constraint(equalTo: safeArea.topAnchor), - tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), - tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), - tableView.bottomAnchor.constraint(equalTo: inputContainer.topAnchor), - - // Input container - inputContainer.leadingAnchor.constraint(equalTo: view.leadingAnchor), - inputContainer.trailingAnchor.constraint(equalTo: view.trailingAnchor), - inputContainer.bottomAnchor.constraint(equalTo: safeArea.bottomAnchor), - inputContainer.heightAnchor.constraint(equalToConstant: 60), - - // Text field - textField.leadingAnchor.constraint(equalTo: inputContainer.leadingAnchor, constant: 8), - textField.centerYAnchor.constraint(equalTo: inputContainer.centerYAnchor), - textField.trailingAnchor.constraint(equalTo: sendButton.leadingAnchor, constant: -8), - textField.heightAnchor.constraint(equalToConstant: 40), - - // Send button - sendButton.trailingAnchor.constraint(equalTo: activityIndicator.leadingAnchor, constant: -8), - sendButton.centerYAnchor.constraint(equalTo: inputContainer.centerYAnchor), - sendButton.widthAnchor.constraint(equalToConstant: 40), - - // Activity indicator - activityIndicator.trailingAnchor.constraint(equalTo: inputContainer.trailingAnchor, constant: -8), - activityIndicator.centerYAnchor.constraint(equalTo: inputContainer.centerYAnchor), + imageView.centerXAnchor.constraint(equalTo: emptyStateView.centerXAnchor), + imageView.topAnchor.constraint(equalTo: emptyStateView.topAnchor), + imageView.widthAnchor.constraint(equalToConstant: 60), + imageView.heightAnchor.constraint(equalToConstant: 60), + + label.topAnchor.constraint(equalTo: imageView.bottomAnchor, constant: 16), + label.leadingAnchor.constraint(equalTo: emptyStateView.leadingAnchor), + label.trailingAnchor.constraint(equalTo: emptyStateView.trailingAnchor), + label.bottomAnchor.constraint(lessThanOrEqualTo: emptyStateView.bottomAnchor) ]) + + // Initially hidden, will show if no chats + emptyStateView.isHidden = true } - - private func setupKeyboardHandling() { - // Add keyboard notifications - NotificationCenter.default.addObserver( - self, - selector: #selector(keyboardWillShow), - name: UIResponder.keyboardWillShowNotification, - object: nil - ) - - NotificationCenter.default.addObserver( - self, - selector: #selector(keyboardWillHide), - name: UIResponder.keyboardWillHideNotification, - object: nil - ) - - // Add tap gesture recognizer to dismiss keyboard when tapping outside - let tapGesture = UITapGestureRecognizer(target: self, action: #selector(dismissKeyboard)) - tapGesture.cancelsTouchesInView = false - view.addGestureRecognizer(tapGesture) - } - - @objc private func dismissKeyboard() { - view.endEditing(true) - } - - @objc private func keyboardWillShow(notification: NSNotification) { - guard let keyboardFrame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect, - let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double - else { - return - } - - let keyboardHeight = keyboardFrame.height - - UIView.animate(withDuration: duration) { [weak self] in - guard let self = self else { return } - self.inputContainer.transform = CGAffineTransform(translationX: 0, y: -keyboardHeight) - self.tableView.contentInset.bottom = keyboardHeight + 60 - self.scrollToBottom() - } - } - - @objc private func keyboardWillHide(notification: NSNotification) { - guard let duration = notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? Double else { - return - } - - UIView.animate(withDuration: duration) { [weak self] in - guard let self = self else { return } - self.inputContainer.transform = .identity - self.tableView.contentInset.bottom = 0 - self.scrollToBottom() - } - } - + // MARK: - Data Loading - - private func loadMessages() { - // Fetch messages from CoreData - let fetchedMessages = CoreDataManager.shared.getMessages(for: currentSession) - - // Handle the case where no messages are found - if fetchedMessages.isEmpty && messages.isEmpty { - Debug.shared.log(message: "No messages found for chat session", type: .debug) - // Show empty state view when no messages exist - tableView.backgroundView?.isHidden = false - } else { - messages = fetchedMessages - // Hide empty state view when messages exist - tableView.backgroundView?.isHidden = true - tableView.reloadData() - scrollToBottom(animated: false) - } - - // Update the UI with fade animation if there's a significant change - if !messages.isEmpty != !fetchedMessages.isEmpty { - UIView.animate(withDuration: 0.3) { - self.tableView.backgroundView?.alpha = fetchedMessages.isEmpty ? 1.0 : 0.0 + + private func loadRecentSessions() { + // Load recent chat sessions from CoreData + do { + recentSessions = CoreDataManager.shared.fetchRecentChatSessions(limit: 20) + + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + // Show/hide empty state based on data + self.emptyStateView.isHidden = !self.recentSessions.isEmpty + self.recentChatsTableView.reloadData() } - } - } - - private func scrollToBottom(animated: Bool = true) { - // Ensure we have messages and the table view is loaded - if !messages.isEmpty && tableView.window != nil { - let indexPath = IndexPath(row: messages.count - 1, section: 0) - - // Safely scroll to bottom - if indexPath.row < tableView.numberOfRows(inSection: 0) { - tableView.scrollToRow(at: indexPath, at: .bottom, animated: animated) + } catch { + Debug.shared.log(message: "Failed to load recent chat sessions: \(error.localizedDescription)", type: .error) + + // Show empty state on error + DispatchQueue.main.async { [weak self] in + self?.emptyStateView.isHidden = false } } } - + // MARK: - Actions - - @objc private func showHistory() { - let historyVC = ChatHistoryViewController() - historyVC.didSelectSession = { [weak self] session in - self?.loadSession(session) - } - let navController = UINavigationController(rootViewController: historyVC) - - // Use half sheet style on newer iOS versions - if #available(iOS 15.0, *), let sheet = navController.sheetPresentationController { - sheet.detents = [.medium(), .large()] - sheet.prefersGrabberVisible = true - sheet.preferredCornerRadius = 20 - } else { - navController.modalPresentationStyle = .formSheet - } - - present(navController, animated: true) - } - - @objc private func newChat() { - // If already processing a message, don't allow creating a new chat - if isProcessingMessage { - Debug.shared.log(message: "Ignored new chat request while processing message", type: .warning) - return - } - - let title = "Chat on \(DateFormatter.localizedString(from: Date(), dateStyle: .medium, timeStyle: .short))" + + @objc private func startNewChat() { + // Create a new chat session do { - currentSession = try CoreDataManager.shared.createAIChatSession(title: title) - messages = [] - tableView.reloadData() - navigationItem.title = currentSession.title - - // Add welcome message to the new chat - addWelcomeMessage() - - // Give feedback to user - let generator = UIImpactFeedbackGenerator(style: .medium) - generator.impactOccurred() - } catch { - Debug.shared.log(message: "Failed to create new chat session: \(error)", type: .error) - showErrorAlert(message: "Failed to create new chat session") - } - } - - /// Load a different chat session - func loadSession(_ session: ChatSession) { - if isProcessingMessage { - Debug.shared.log(message: "Ignored session change while processing message", type: .warning) - return - } - - currentSession = session - loadMessages() - navigationItem.title = session.title - } - - @objc private func sendMessage() { - // Ensure we're not already processing a message - if isProcessingMessage { - Debug.shared.log(message: "Ignored message send while already processing", type: .warning) - return - } - - // Get and validate text - guard let text = textField.text?.trimmingCharacters(in: .whitespacesAndNewlines), !text.isEmpty else { - return - } - - // Clear text field immediately for better UX - textField.text = "" - - // Update UI to show message sending - activityIndicator.startAnimating() - sendButton.isEnabled = false - textField.isEnabled = false - isProcessingMessage = true - - // Create a background task ID to handle possible app backgrounding during message processing - var backgroundTaskID: UIBackgroundTaskIdentifier = .invalid - backgroundTaskID = UIApplication.shared.beginBackgroundTask { [weak self] in - // If background time is about to expire, ensure we clean up - self?.handleMessageProcessingTimeout() - - if backgroundTaskID != .invalid { - UIApplication.shared.endBackgroundTask(backgroundTaskID) - backgroundTaskID = .invalid - } - } - - // Process message in a try-catch block for better error handling - do { - // Add user message to database and update UI - let userMessage = try CoreDataManager.shared.addMessage(to: currentSession, sender: "user", content: text) - messages.append(userMessage) - tableView.reloadData() - scrollToBottom() - - // Get current app context for AI relevance - let context = AppContextManager.shared.currentContext() - - // Get additional context information from our custom provider - let contextSummary = CustomAIContextProvider.shared.getContextSummary() - Debug.shared.log(message: "AI context: \(contextSummary)", type: .debug) - - // Convert CoreData ChatMessages to payload format - let apiMessages = messages.map { - OpenAIService.AIMessagePayload( - role: $0.sender == "user" ? "user" : ($0.sender == "ai" ? "assistant" : "system"), - content: $0.content ?? "" - ) + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .medium + dateFormatter.timeStyle = .short + let timestamp = dateFormatter.string(from: Date()) + let title = "Chat on \(timestamp)" + + guard let session = try? CoreDataManager.shared.createAIChatSession(title: title) else { + throw NSError(domain: "com.backdoor.aiViewController", code: 1, + userInfo: [NSLocalizedDescriptionKey: "Failed to create chat session"]) } - - // Create temporary "typing" indicator with delay to avoid flashing - var typingMessageID: String? = nil - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - guard let self = self, self.isProcessingMessage else { return } - - do { - let typingMessage = try CoreDataManager.shared.addMessage( - to: self.currentSession, - sender: "system", - content: "Assistant is thinking..." - ) - typingMessageID = typingMessage.messageID - self.messages.append(typingMessage) - self.tableView.reloadData() - self.scrollToBottom() - } catch { - Debug.shared.log(message: "Failed to add typing indicator: \(error)", type: .debug) + + // Present chat view controller + let chatVC = ChatViewController(session: session) + + // Set dismiss handler to refresh the list + chatVC.dismissHandler = { [weak self] in + DispatchQueue.main.async { + self?.loadRecentSessions() } } - - // Give haptic feedback when sending message - let generator = UIImpactFeedbackGenerator(style: .light) - generator.impactOccurred() - - // Call AI service with proper error handling - OpenAIService.shared.getAIResponse(messages: apiMessages, context: context) { [weak self] result in - // Ensure UI updates happen on main thread - DispatchQueue.main.async { - guard let self = self else { return } - - // Reset UI state - self.activityIndicator.stopAnimating() - self.sendButton.isEnabled = true - self.textField.isEnabled = true - self.isProcessingMessage = false - - // End background task if still active - if backgroundTaskID != .invalid { - UIApplication.shared.endBackgroundTask(backgroundTaskID) - } - - // Remove typing indicator if it exists - if let typingID = typingMessageID { - // Find and remove typing message from message array - if let index = self.messages.firstIndex(where: { $0.messageID == typingID }) { - self.messages.remove(at: index) - } - // Remove from database - CoreDataManager.shared.deleteMessage(withID: typingID) - } - - switch result { - case let .success(response): - do { - // Add AI message to database - let aiMessage = try CoreDataManager.shared.addMessage( - to: self.currentSession, - sender: "ai", - content: response - ) - self.messages.append(aiMessage) - self.tableView.reloadData() - self.scrollToBottom() - - // Record the interaction for AI learning - self.recordAIInteraction( - userMessage: text, - aiResponse: response, - messageId: aiMessage.messageID ?? UUID().uuidString - ) - - // Extract and process any commands in the response - self.processCommands(from: response) - - // Give haptic feedback for successful response - let generator = UIImpactFeedbackGenerator(style: .light) - generator.impactOccurred() - } catch { - Debug.shared.log(message: "Failed to add AI message: \(error)", type: .error) - self.showErrorAlert(message: "Failed to save AI response") - } - case let .failure(error): - do { - // Show error in chat - let errorMessage = try CoreDataManager.shared.addMessage( - to: self.currentSession, - sender: "system", - content: "Error: \(error.localizedDescription)" - ) - self.messages.append(errorMessage) - self.tableView.reloadData() - self.scrollToBottom() - - // Provide a more helpful error message based on error type - if case let OpenAIService.ServiceError.processingError(reason) = error { - let helpMessage = try CoreDataManager.shared.addMessage( - to: self.currentSession, - sender: "system", - content: "The assistant encountered a processing error: \(reason). Please try again with a different question." - ) - self.messages.append(helpMessage) - self.tableView.reloadData() - self.scrollToBottom() - } - - // Haptic feedback for error - let generator = UINotificationFeedbackGenerator() - generator.notificationOccurred(.error) - } catch { - Debug.shared.log(message: "Failed to add error message: \(error)", type: .error) - self.showErrorAlert(message: "Failed to save error message") - } + + // Present chat view controller + let navController = UINavigationController(rootViewController: chatVC) + + // Configure presentation style based on device + if UIDevice.current.userInterfaceIdiom == .pad { + // iPad-specific presentation + navController.modalPresentationStyle = .formSheet + navController.preferredContentSize = CGSize(width: 540, height: 620) + } else { + // iPhone presentation + if #available(iOS 15.0, *) { + if let sheet = navController.sheetPresentationController { + // Use sheet presentation for iOS 15+ + sheet.detents = [.medium(), .large()] + sheet.prefersGrabberVisible = true + sheet.preferredCornerRadius = 24 + + // Add delegate to handle dismissal properly + sheet.delegate = chatVC } + } else { + // Fallback for older iOS versions + navController.modalPresentationStyle = .fullScreen } } + + present(navController, animated: true) + } catch { - // Handle failure to save user message - activityIndicator.stopAnimating() - sendButton.isEnabled = true - textField.isEnabled = true - isProcessingMessage = false - - // End background task if still active - if backgroundTaskID != .invalid { - UIApplication.shared.endBackgroundTask(backgroundTaskID) - } - - Debug.shared.log(message: "Failed to add user message: \(error)", type: .error) - showErrorAlert(message: "Failed to save your message") + Debug.shared.log(message: "Failed to create chat session: \(error.localizedDescription)", type: .error) + + // Show error alert + let alert = UIAlertController( + title: "Chat Error", + message: "Failed to start a new chat. Please try again later.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true) } } +} - /// Handle timeout of message processing (e.g., when app is backgrounded for too long) - private func handleMessageProcessingTimeout() { - DispatchQueue.main.async { [weak self] in - guard let self = self, self.isProcessingMessage else { return } - - // Reset UI state - self.activityIndicator.stopAnimating() - self.sendButton.isEnabled = true - self.textField.isEnabled = true - self.isProcessingMessage = false +// MARK: - UITableViewDataSource - do { - // Add a system message about the timeout - let timeoutMessage = try CoreDataManager.shared.addMessage( - to: self.currentSession, - sender: "system", - content: "Message processing was interrupted. Please try again." - ) - self.messages.append(timeoutMessage) - self.tableView.reloadData() - self.scrollToBottom() - } catch { - Debug.shared.log(message: "Failed to add timeout message: \(error)", type: .error) - } - } +extension AIViewController: UITableViewDataSource { + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + return recentSessions.count } - - // MARK: - Command Processing - - /// Process commands extracted from AI response - private func processCommands(from response: String) { - // Extract commands using regex - let commands = extractCommands(from: response) - - // Process each command - for (command, parameter) in commands { - // Use weak self to prevent retain cycles - AppContextManager.shared.executeCommand(command, parameter: parameter) { [weak self] commandResult in - // Ensure UI updates happen on main thread - DispatchQueue.main.async { - guard let self = self else { return } - - let systemMessageContent: String - switch commandResult { - case let .successWithResult(message): - systemMessageContent = message - case let .unknownCommand(cmd): - systemMessageContent = "Unknown command: \(cmd)" - } - - do { - // Add system message showing command result - let systemMessage = try CoreDataManager.shared.addMessage( - to: self.currentSession, - sender: "system", - content: systemMessageContent - ) - self.messages.append(systemMessage) - self.tableView.reloadData() - self.scrollToBottom() - } catch { - Debug.shared.log(message: "Failed to add system message: \(error)", type: .error) - } - } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + let cell = tableView.dequeueReusableCell(withIdentifier: "ChatSessionCell", for: indexPath) + + // Configure cell + let session = recentSessions[indexPath.row] + + // Use modern cell configuration if available + if #available(iOS 14.0, *) { + var content = cell.defaultContentConfiguration() + content.text = session.title + + // Format date + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .medium + dateFormatter.timeStyle = .short + + if let date = session.createdAt { + content.secondaryText = dateFormatter.string(from: date) + } + + content.image = UIImage(systemName: "bubble.left.and.bubble.right.fill") + content.imageProperties.tintColor = Preferences.appTintColor.uiColor + + cell.contentConfiguration = content + } else { + // Fallback for older iOS versions + cell.textLabel?.text = session.title + + // Format date + let dateFormatter = DateFormatter() + dateFormatter.dateStyle = .medium + dateFormatter.timeStyle = .short + + if let date = session.createdAt { + cell.detailTextLabel?.text = dateFormatter.string(from: date) } } + + cell.accessoryType = .disclosureIndicator + + return cell } + + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + return recentSessions.isEmpty ? nil : "Recent Chats" + } +} - // Extract commands from AI response text - private func extractCommands(from text: String) -> [(command: String, parameter: String)] { - let pattern = "\\[([^:]+):([^\\]]+)\\]" - do { - let regex = try NSRegularExpression(pattern: pattern) - let matches = regex.matches(in: text, range: NSRange(text.startIndex..., in: text)) - return matches.compactMap { match in - if let commandRange = Range(match.range(at: 1), in: text), - let paramRange = Range(match.range(at: 2), in: text) - { - return (String(text[commandRange]), String(text[paramRange])) - } - return nil +// MARK: - UITableViewDelegate + +extension AIViewController: UITableViewDelegate { + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + + // Get selected session + let session = recentSessions[indexPath.row] + + // Present chat view controller with this session + let chatVC = ChatViewController(session: session) + + // Set dismiss handler to refresh the list + chatVC.dismissHandler = { [weak self] in + DispatchQueue.main.async { + self?.loadRecentSessions() } - } catch { - Debug.shared.log(message: "Failed to create regex for command extraction: \(error)", type: .error) - return [] } - } - - // MARK: - Error Handling - - private func showErrorAlert(message: String) { - let alert = UIAlertController( - title: "Error", - message: message, - preferredStyle: .alert - ) - alert.addAction(UIAlertAction(title: "OK", style: .default)) - - // Check if we can present the alert - if !isBeingDismissed && !isBeingPresented && presentedViewController == nil { - present(alert, animated: true) + + // Present chat view controller + let navController = UINavigationController(rootViewController: chatVC) + + // Configure presentation style based on device + if UIDevice.current.userInterfaceIdiom == .pad { + // iPad-specific presentation + navController.modalPresentationStyle = .formSheet + navController.preferredContentSize = CGSize(width: 540, height: 620) } else { - Debug.shared.log(message: "Could not present error alert: \(message)", type: .error) + // iPhone presentation + if #available(iOS 15.0, *) { + if let sheet = navController.sheetPresentationController { + // Use sheet presentation for iOS 15+ + sheet.detents = [.medium(), .large()] + sheet.prefersGrabberVisible = true + sheet.preferredCornerRadius = 24 + + // Add delegate to handle dismissal properly + sheet.delegate = chatVC + } + } else { + // Fallback for older iOS versions + navController.modalPresentationStyle = .fullScreen + } } + + present(navController, animated: true) } - - // MARK: - UITableViewDataSource - - func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int { - return messages.count - } - - func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { - guard indexPath.row < messages.count else { - return UITableViewCell() // Safety check - } - - let message = messages[indexPath.row] - switch message.sender { - case "user": - let cell = tableView.dequeueReusableCell(withIdentifier: "UserCell", for: indexPath) as! UserMessageCell - cell.configure(with: message) - return cell - case "ai": - let cell = tableView.dequeueReusableCell(withIdentifier: "AICell", for: indexPath) as! AIMessageCell - cell.configure(with: message) - return cell - case "system": - let cell = tableView.dequeueReusableCell(withIdentifier: "SystemCell", for: indexPath) as! SystemMessageCell - cell.configure(with: message) - return cell - default: - return UITableViewCell() + + func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) { + if editingStyle == .delete { + // Get session to delete + let session = recentSessions[indexPath.row] + + // Delete from CoreData + do { + try CoreDataManager.shared.deleteChatSession(session) + + // Update local array + recentSessions.remove(at: indexPath.row) + + // Update UI + tableView.deleteRows(at: [indexPath], with: .fade) + + // Show empty state if needed + emptyStateView.isHidden = !recentSessions.isEmpty + + } catch { + Debug.shared.log(message: "Failed to delete chat session: \(error.localizedDescription)", type: .error) + + // Show error alert + let alert = UIAlertController( + title: "Delete Error", + message: "Failed to delete the chat session. Please try again.", + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true) + } } } - - // MARK: - UISheetPresentationControllerDelegate - - // Handle sheet dismissal properly - func presentationControllerDidDismiss(_: UIPresentationController) { - dismissHandler?() - } } -// MARK: - UITextFieldDelegate +// MARK: - View Controller Refreshable -extension ChatViewController: UITextFieldDelegate { - func textFieldShouldReturn(_: UITextField) -> Bool { - sendMessage() - return true +extension AIViewController: ViewControllerRefreshable { + override func refreshContent() { + // Reload data when tab is selected + loadRecentSessions() } -} Assistant/ChatViewController.swift +} Assistant/AIViewController.swift