// // AircraftTrackingViewModel.swift // LearnMapKit // // Main ViewModel coordinating all aircraft tracking functionality // import Foundation import Combine import MapKit import SwiftUI @MainActor class AircraftTrackingViewModel: ObservableObject { // MARK: - Published Properties (UI State) @Published var aircraft: [Aircraft] = [] @Published var selectedAircraft: Aircraft? @Published var connectionState: DataSourceState = .idle @Published var mapRegion: MKCoordinateRegion @Published var aircraftCount: Int = 0 @Published var isLoading: Bool = false @Published var errorMessage: String? @Published var showingSettings: Bool = false // MARK: - Configuration @Published var currentConfiguration: AppConfiguration // MARK: - Dependencies private let configurationManager: ConfigurationManager private let aircraftRepository: AircraftRepository private var currentDataSource: (any ADSBDataSource)? private let dataSourceFactory: DataSourceFactory // MARK: - Private Properties private var cancellables = Set() // MARK: - Initialization init( configurationManager: ConfigurationManager, aircraftRepository: AircraftRepository, dataSourceFactory: DataSourceFactory ) { self.configurationManager = configurationManager self.aircraftRepository = aircraftRepository self.dataSourceFactory = dataSourceFactory self.currentConfiguration = configurationManager.getCurrentConfiguration() // Initialize map region from configuration let mapSettings = currentConfiguration.mapSettings self.mapRegion = MKCoordinateRegion( center: mapSettings.defaultCenter, span: mapSettings.defaultSpan ) setupBindings() } // MARK: - Public Interface func startTracking() async { guard connectionState != .connected && connectionState != .connecting else { return } isLoading = true errorMessage = nil do { // Create and configure data source currentDataSource = try createDataSource() // Start the data source try await currentDataSource?.start() print("Aircraft tracking started") } catch { errorMessage = "Failed to start tracking: \(error.localizedDescription)" connectionState = .error(error.localizedDescription) } isLoading = false } func stopTracking() async { await currentDataSource?.stop() currentDataSource = nil connectionState = .disconnected } func reconnect() async { guard let dataSource = currentDataSource else { await startTracking() return } isLoading = true do { try await dataSource.reconnect() } catch { errorMessage = "Reconnection failed: \(error.localizedDescription)" } isLoading = false } func selectAircraft(_ aircraft: Aircraft?) { selectedAircraft = aircraft // Center map on selected aircraft if let aircraft = aircraft, let position = aircraft.position { withAnimation(.easeInOut(duration: 0.5)) { mapRegion = MKCoordinateRegion( center: CLLocationCoordinate2D( latitude: position.latitude, longitude: position.longitude ), span: MKCoordinateSpan(latitudeDelta: 0.05, longitudeDelta: 0.05) ) } } } func updateConfiguration(_ configuration: AppConfiguration) async { // Validate configuration first let validation = configurationManager.validateConfiguration(configuration) guard validation.isValid else { errorMessage = "Invalid configuration: \(validation.errors.joined(separator: ", "))" return } let wasConnected = connectionState == .connected // Stop current tracking if running if wasConnected { await stopTracking() } // Update configuration await configurationManager.updateConfiguration(configuration) // Update map region if changed let newMapSettings = configuration.mapSettings mapRegion = MKCoordinateRegion( center: newMapSettings.defaultCenter, span: newMapSettings.defaultSpan ) // Restart tracking if it was running if wasConnected { await startTracking() } } func clearAircraftData() { aircraftRepository.clearAll() selectedAircraft = nil } func exportAircraftData() -> String { // Export current aircraft data as JSON or CSV let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 encoder.outputFormatting = .prettyPrinted do { let data = try encoder.encode(aircraft) return String(data: data, encoding: .utf8) ?? "Export failed" } catch { return "Export error: \(error.localizedDescription)" } } // MARK: - Configuration Shortcuts func switchToFileMode(filePath: String) async { let fileConfig = FileDataSourceConfiguration(filePath: filePath) var newConfig = currentConfiguration newConfig = AppConfiguration( dataSource: fileConfig, displaySettings: newConfig.displaySettings, mapSettings: newConfig.mapSettings, networkSettings: newConfig.networkSettings ) await updateConfiguration(newConfig) } func switchToNetworkMode(hostname: String, port: Int) async { let networkConfig = NetworkDataSourceConfiguration(hostname: hostname, port: port) var newConfig = currentConfiguration newConfig = AppConfiguration( dataSource: networkConfig, displaySettings: newConfig.displaySettings, mapSettings: newConfig.mapSettings, networkSettings: newConfig.networkSettings ) await updateConfiguration(newConfig) } // MARK: - Private Methods private func setupBindings() { // Bind configuration updates configurationManager.currentConfiguration .receive(on: DispatchQueue.main) .assign(to: &$currentConfiguration) // Bind aircraft updates aircraftRepository.aircraftStream .receive(on: DispatchQueue.main) .assign(to: &$aircraft) // Bind aircraft count aircraftRepository.aircraftCount .receive(on: DispatchQueue.main) .assign(to: &$aircraftCount) // Update selected aircraft when aircraft list changes aircraftRepository.aircraftStream .receive(on: DispatchQueue.main) .sink { [weak self] aircraft in self?.updateSelectedAircraft(from: aircraft) } .store(in: &cancellables) } private func createDataSource() throws -> any ADSBDataSource { let dataSource = try dataSourceFactory.createDataSource( for: currentConfiguration.dataSource ) // Bind data source streams dataSource.connectionState .receive(on: DispatchQueue.main) .assign(to: &$connectionState) dataSource.dataStream .receive(on: DispatchQueue.main) .sink { [weak self] event in self?.aircraftRepository.updateAircraft(with: event) } .store(in: &cancellables) return dataSource } private func updateSelectedAircraft(from aircraftList: [Aircraft]) { // Update selected aircraft if it still exists if let selected = selectedAircraft { selectedAircraft = aircraftList.first { $0.id == selected.id } } } } // MARK: - Data Source Factory protocol DataSourceFactory { func createDataSource(for configuration: DataSourceConfiguration) throws -> any ADSBDataSource } class DefaultDataSourceFactory: DataSourceFactory { func createDataSource(for configuration: DataSourceConfiguration) throws -> any ADSBDataSource { switch configuration.sourceType { case .file: guard let fileConfig = configuration as? FileDataSourceConfiguration else { throw DataSourceFactoryError.invalidConfiguration } return FileADSBDataSource(configuration: fileConfig) case .network: guard let networkConfig = configuration as? NetworkDataSourceConfiguration else { throw DataSourceFactoryError.invalidConfiguration } return NetworkADSBDataSource(configuration: networkConfig) case .test: return TestADSBDataSource(configuration: configuration) } } } enum DataSourceFactoryError: Error { case invalidConfiguration case unsupportedDataSourceType } // MARK: - Test Data Source class TestADSBDataSource: ADSBDataSource { var dataStream: AnyPublisher { Empty().eraseToAnyPublisher() } var connectionState: AnyPublisher { Just(.connected).eraseToAnyPublisher() } private(set) var configuration: DataSourceConfiguration init(configuration: DataSourceConfiguration) { self.configuration = configuration } func configure(with config: DataSourceConfiguration) async { self.configuration = config } func start() async throws { // Test implementation } func stop() async { // Test implementation } func reconnect() async throws { // Test implementation } } // MARK: - Error Handling Extension extension AircraftTrackingViewModel { func handleError(_ error: Error) { errorMessage = error.localizedDescription connectionState = .error(error.localizedDescription) } func clearError() { errorMessage = nil if case .error = connectionState { connectionState = .disconnected } } }