diff options
| author | Arturs Artamonovs <arturs.artamonovs@protonmail.com> | 2025-11-17 00:44:09 +0000 |
|---|---|---|
| committer | Arturs Artamonovs <arturs.artamonovs@protonmail.com> | 2025-11-17 00:44:09 +0000 |
| commit | e26830439dfdb97c1d685bf5cda05a5d2df9f21f (patch) | |
| tree | 85a16662d47787d0975c05a22727ad4301d0fb5b | |
| parent | 2b599499dc26f0f93537d1bea4733eae68333a1c (diff) | |
| download | ADSBDecoder-e26830439dfdb97c1d685bf5cda05a5d2df9f21f.tar.gz ADSBDecoder-e26830439dfdb97c1d685bf5cda05a5d2df9f21f.zip | |
refactor experiment
| -rw-r--r-- | LearnMapKit/App/RefactoredApp.swift | 277 | ||||
| -rw-r--r-- | LearnMapKit/DI/DependencyContainer.swift | 118 | ||||
| -rw-r--r-- | LearnMapKit/DataSources/ADSBDataSource.swift | 92 | ||||
| -rw-r--r-- | LearnMapKit/DataSources/FileADSBDataSource.swift | 185 | ||||
| -rw-r--r-- | LearnMapKit/DataSources/NetworkADSBDataSource.swift | 201 | ||||
| -rw-r--r-- | LearnMapKit/Managers/ConfigurationManager.swift | 322 | ||||
| -rw-r--r-- | LearnMapKit/Repositories/AircraftRepository.swift | 265 | ||||
| -rw-r--r-- | LearnMapKit/ViewModels/AircraftTrackingViewModel.swift | 327 |
8 files changed, 1787 insertions, 0 deletions
diff --git a/LearnMapKit/App/RefactoredApp.swift b/LearnMapKit/App/RefactoredApp.swift new file mode 100644 index 0000000..fbd7417 --- /dev/null +++ b/LearnMapKit/App/RefactoredApp.swift @@ -0,0 +1,277 @@ +// +// RefactoredApp.swift +// LearnMapKit +// +// Refactored app entry point with dependency injection +// + +import SwiftUI +import ArgumentParser + +@main +struct RefactoredLearnMapKitApp: App { + private let container = DependencyContainer() + + init() { + setupFromCommandLineArguments() + } + + var body: some Scene { + WindowGroup { + MainContentView() + .environmentObject(container.makeAircraftTrackingViewModel()) + .environmentObject(container.configurationManager) + } + } + + private func setupFromCommandLineArguments() { + guard let args = parseCommandLineArguments() else { return } + + Task { + // Configure based on command line arguments + if let hostname = args.hostname, let port = args.port { + await configureNetworkMode(hostname: hostname, port: port) + } else if let inputFile = args.inputfile { + await configureFileMode(filePath: inputFile) + } + // Otherwise use default configuration + } + } + + private func parseCommandLineArguments() -> CommandLineArgs? { + do { + return try CommandLineArgs.parse() + } catch { + // If parsing fails, continue with default configuration + print("Command line parsing failed: \(error)") + return nil + } + } + + private func configureNetworkMode(hostname: String, port: Int) async { + let networkConfig = NetworkDataSourceConfiguration(hostname: hostname, port: port) + let config = AppConfiguration( + dataSource: networkConfig, + displaySettings: DisplaySettings(), + mapSettings: MapSettings(), + networkSettings: NetworkSettings() + ) + await container.configurationManager.updateConfiguration(config) + } + + private func configureFileMode(filePath: String) async { + let fileConfig = FileDataSourceConfiguration(filePath: filePath) + let config = AppConfiguration( + dataSource: fileConfig, + displaySettings: DisplaySettings(), + mapSettings: MapSettings(), + networkSettings: NetworkSettings() + ) + await container.configurationManager.updateConfiguration(config) + } +} + +// MARK: - Main Content View +struct MainContentView: View { + @EnvironmentObject var viewModel: AircraftTrackingViewModel + @EnvironmentObject var configurationManager: ConfigurationManager + + var body: some View { + NavigationView { + ZStack { + AircraftMapView( + aircraft: viewModel.aircraft, + selectedAircraft: viewModel.selectedAircraft, + mapRegion: $viewModel.mapRegion, + onAircraftSelected: { aircraft in + viewModel.selectAircraft(aircraft) + } + ) + + VStack { + Spacer() + HStack { + ConnectionStatusBar( + state: viewModel.connectionState, + aircraftCount: viewModel.aircraftCount + ) + Spacer() + } + .padding() + } + + if viewModel.isLoading { + LoadingOverlay() + } + } + .navigationTitle("Aircraft Tracking") + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + ConnectionControlButton( + connectionState: viewModel.connectionState, + onStart: { + Task { await viewModel.startTracking() } + }, + onStop: { + Task { await viewModel.stopTracking() } + }, + onReconnect: { + Task { await viewModel.reconnect() } + } + ) + + Button("Settings") { + viewModel.showingSettings = true + } + } + } + .sheet(isPresented: $viewModel.showingSettings) { + SettingsView() + .environmentObject(viewModel) + } + .alert("Error", isPresented: .constant(viewModel.errorMessage != nil)) { + Button("OK") { + viewModel.clearError() + } + } message: { + Text(viewModel.errorMessage ?? "") + } + } + .task { + // Auto-start tracking when view appears + await viewModel.startTracking() + } + } +} + +// MARK: - Supporting Views +struct ConnectionStatusBar: View { + let state: DataSourceState + let aircraftCount: Int + + var body: some View { + HStack(spacing: 12) { + StatusIndicator(state: state) + Text(statusText) + .font(.caption) + .foregroundColor(.secondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 8)) + } + + private var statusText: String { + switch state { + case .connected: + return "Connected • \(aircraftCount) aircraft" + case .connecting: + return "Connecting..." + case .disconnected: + return "Disconnected" + case .error(let message): + return "Error: \(message)" + case .idle: + return "Ready" + case .configuring: + return "Configuring..." + } + } +} + +struct StatusIndicator: View { + let state: DataSourceState + + var body: some View { + Circle() + .fill(indicatorColor) + .frame(width: 8, height: 8) + } + + private var indicatorColor: Color { + switch state { + case .connected: + return .green + case .connecting, .configuring: + return .orange + case .disconnected, .idle: + return .gray + case .error: + return .red + } + } +} + +struct ConnectionControlButton: View { + let connectionState: DataSourceState + let onStart: () -> Void + let onStop: () -> Void + let onReconnect: () -> Void + + var body: some View { + Button(action: buttonAction) { + Image(systemName: buttonIconName) + } + } + + private var buttonIconName: String { + switch connectionState { + case .connected: + return "stop.circle" + case .connecting, .configuring: + return "stop.circle" + case .disconnected, .idle: + return "play.circle" + case .error: + return "arrow.clockwise.circle" + } + } + + private func buttonAction() { + switch connectionState { + case .connected, .connecting, .configuring: + onStop() + case .disconnected, .idle: + onStart() + case .error: + onReconnect() + } + } +} + +struct LoadingOverlay: View { + var body: some View { + ZStack { + Color.black.opacity(0.3) + .ignoresSafeArea() + + VStack(spacing: 16) { + ProgressView() + .scaleEffect(1.2) + Text("Connecting...") + .font(.caption) + .foregroundColor(.secondary) + } + .padding() + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12)) + } + } +} + +// MARK: - Command Line Arguments (Updated) +struct CommandLineArgs: ParsableCommand { + @Option(name: .shortAndLong, help: "Hostname for network mode") + var hostname: String? + + @Option(name: .shortAndLong, help: "Port for network mode") + var port: Int? + + @Option(name: .shortAndLong, help: "Input file path for file mode") + var inputfile: String? + + @Flag(name: .shortAndLong, help: "Enable debug mode") + var debug: Bool = false + + @Flag(name: .shortAndLong, help: "Show version information") + var version: Bool = false +}
\ No newline at end of file diff --git a/LearnMapKit/DI/DependencyContainer.swift b/LearnMapKit/DI/DependencyContainer.swift new file mode 100644 index 0000000..f0d1323 --- /dev/null +++ b/LearnMapKit/DI/DependencyContainer.swift @@ -0,0 +1,118 @@ +// +// DependencyContainer.swift +// LearnMapKit +// +// Dependency injection container for the app +// + +import Foundation + +@MainActor +class DependencyContainer { + + // MARK: - Singletons + lazy var configurationManager: ConfigurationManager = DefaultConfigurationManager() + lazy var aircraftRepository: AircraftRepository = DefaultAircraftRepository(tracker: aircraftTracker) + lazy var dataSourceFactory: DataSourceFactory = DefaultDataSourceFactory() + lazy var aircraftTracker: AircraftTracker = DefaultAircraftTracker() + + // MARK: - ViewModels + func makeAircraftTrackingViewModel() -> AircraftTrackingViewModel { + AircraftTrackingViewModel( + configurationManager: configurationManager, + aircraftRepository: aircraftRepository, + dataSourceFactory: dataSourceFactory + ) + } + + func makeSettingsViewModel() -> SettingsViewModel { + SettingsViewModel(configurationManager: configurationManager) + } + + // MARK: - Data Sources (Factory Methods) + func makeFileDataSource(configuration: FileDataSourceConfiguration) -> FileADSBDataSource { + FileADSBDataSource( + configuration: configuration, + tracker: aircraftTracker + ) + } + + func makeNetworkDataSource(configuration: NetworkDataSourceConfiguration) -> NetworkADSBDataSource { + NetworkADSBDataSource( + configuration: configuration, + tracker: aircraftTracker + ) + } +} + +// MARK: - Settings ViewModel +@MainActor +class SettingsViewModel: ObservableObject { + @Published var configuration: AppConfiguration + @Published var hasUnsavedChanges: Bool = false + @Published var validationErrors: [String] = [] + @Published var isValid: Bool = true + + private let configurationManager: ConfigurationManager + private var cancellables = Set<AnyCancellable>() + + init(configurationManager: ConfigurationManager) { + self.configurationManager = configurationManager + self.configuration = configurationManager.getCurrentConfiguration() + + setupBindings() + } + + func saveSettings() async { + let validation = configurationManager.validateConfiguration(configuration) + validationErrors = validation.errors + isValid = validation.isValid + + if validation.isValid { + do { + await configurationManager.updateConfiguration(configuration) + try await configurationManager.saveConfiguration() + } catch { + validationErrors = ["Failed to save: \(error.localizedDescription)"] + isValid = false + } + } + } + + func resetToDefaults() async { + configuration = AppConfiguration.default + await configurationManager.resetToDefaults() + } + + func validateCurrentSettings() { + let validation = configurationManager.validateConfiguration(configuration) + validationErrors = validation.errors + isValid = validation.isValid + } + + private func setupBindings() { + configurationManager.hasUnsavedChanges + .receive(on: DispatchQueue.main) + .assign(to: &$hasUnsavedChanges) + + $configuration + .removeDuplicates() + .sink { [weak self] _ in + self?.validateCurrentSettings() + } + .store(in: &cancellables) + } +} + +// MARK: - Missing Protocol Implementations (Stubs) +protocol ADSBMessageDecoder { + func decode(_ message: String) -> ADSBDataEvent? +} + +class DefaultADSBMessageDecoder: ADSBMessageDecoder { + func decode(_ message: String) -> ADSBDataEvent? { + // This would contain the actual decoding logic + // For now, return nil as a stub + return nil + } +}
\ No newline at end of file diff --git a/LearnMapKit/DataSources/ADSBDataSource.swift b/LearnMapKit/DataSources/ADSBDataSource.swift new file mode 100644 index 0000000..b30e344 --- /dev/null +++ b/LearnMapKit/DataSources/ADSBDataSource.swift @@ -0,0 +1,92 @@ +// +// ADSBDataSource.swift +// LearnMapKit +// +// Created for MVVM refactoring +// + +import Foundation +import Combine + +// MARK: - Data Source Protocol +protocol ADSBDataSource: AnyObject { + var dataStream: AnyPublisher<ADSBDataEvent, Never> { get } + var connectionState: AnyPublisher<DataSourceState, Never> { get } + var configuration: DataSourceConfiguration { get } + + func configure(with config: DataSourceConfiguration) async + func start() async throws + func stop() async + func reconnect() async throws +} + +// MARK: - Data Events +enum ADSBDataEvent { + case aircraftIdentification(address: Int, icaoName: String) + case aircraftPosition(address: Int, latitude: Double, longitude: Double) + case aircraftAltitude(address: Int, altitude: Int) + case rawMessage(String) + case error(Error) +} + +// MARK: - Data Source State +enum DataSourceState: Equatable { + case idle + case configuring + case connecting + case connected + case disconnected + case error(String) + + static func == (lhs: DataSourceState, rhs: DataSourceState) -> Bool { + switch (lhs, rhs) { + case (.idle, .idle), (.configuring, .configuring), + (.connecting, .connecting), (.connected, .connected), + (.disconnected, .disconnected): + return true + case let (.error(lhsError), .error(rhsError)): + return lhsError == rhsError + default: + return false + } + } +} + +// MARK: - Configuration +protocol DataSourceConfiguration { + var sourceType: DataSourceType { get } +} + +enum DataSourceType { + case file(path: String) + case network(host: String, port: Int) + case test(mockData: [String]) +} + +struct FileDataSourceConfiguration: DataSourceConfiguration { + let sourceType: DataSourceType + let filePath: String + let processRate: Int // messages per second + + init(filePath: String, processRate: Int = 120) { + self.filePath = filePath + self.processRate = processRate + self.sourceType = .file(path: filePath) + } +} + +struct NetworkDataSourceConfiguration: DataSourceConfiguration { + let sourceType: DataSourceType + let hostname: String + let port: Int + let reconnectAttempts: Int + let timeoutInterval: TimeInterval + + init(hostname: String, port: Int, reconnectAttempts: Int = 3, timeoutInterval: TimeInterval = 10.0) { + self.hostname = hostname + self.port = port + self.reconnectAttempts = reconnectAttempts + self.timeoutInterval = timeoutInterval + self.sourceType = .network(host: hostname, port: port) + } +}
\ No newline at end of file diff --git a/LearnMapKit/DataSources/FileADSBDataSource.swift b/LearnMapKit/DataSources/FileADSBDataSource.swift new file mode 100644 index 0000000..448721b --- /dev/null +++ b/LearnMapKit/DataSources/FileADSBDataSource.swift @@ -0,0 +1,185 @@ +// +// FileADSBDataSource.swift +// LearnMapKit +// +// File-based ADSB data source implementation +// + +import Foundation +import Combine + +@MainActor +class FileADSBDataSource: ADSBDataSource { + + // MARK: - ADSBDataSource Protocol + var dataStream: AnyPublisher<ADSBDataEvent, Never> { + dataSubject.eraseToAnyPublisher() + } + + var connectionState: AnyPublisher<DataSourceState, Never> { + stateSubject.eraseToAnyPublisher() + } + + private(set) var configuration: DataSourceConfiguration + + // MARK: - Private Properties + private let dataSubject = PassthroughSubject<ADSBDataEvent, Never>() + private let stateSubject = CurrentValueSubject<DataSourceState, Never>(.idle) + + private var fileRunner: ADSBFileRunner? + private var processingTask: Task<Void, Error>? + private var timer: Timer? + + // MARK: - Dependencies + private let decoder: ADSBMessageDecoder + private let tracker: AircraftTracker + + init(configuration: FileDataSourceConfiguration, + decoder: ADSBMessageDecoder = DefaultADSBMessageDecoder(), + tracker: AircraftTracker = DefaultAircraftTracker()) { + self.configuration = configuration + self.decoder = decoder + self.tracker = tracker + } + + // MARK: - ADSBDataSource Implementation + func configure(with config: DataSourceConfiguration) async { + guard let fileConfig = config as? FileDataSourceConfiguration else { + stateSubject.send(.error("Invalid configuration type for FileADSBDataSource")) + return + } + + stateSubject.send(.configuring) + self.configuration = fileConfig + stateSubject.send(.idle) + } + + func start() async throws { + guard let fileConfig = configuration as? FileDataSourceConfiguration else { + throw ADSBDataSourceError.invalidConfiguration + } + + stateSubject.send(.connecting) + + do { + // Initialize file runner + fileRunner = ADSBFileRunner(filename: fileConfig.filePath) + + // Start file processing + try await startFileProcessing(with: fileConfig) + + stateSubject.send(.connected) + } catch { + stateSubject.send(.error(error.localizedDescription)) + throw error + } + } + + func stop() async { + timer?.invalidate() + timer = nil + + processingTask?.cancel() + processingTask = nil + + fileRunner = nil + stateSubject.send(.disconnected) + } + + func reconnect() async throws { + await stop() + try await start() + } + + // MARK: - Private Methods + private func startFileProcessing(with config: FileDataSourceConfiguration) async throws { + guard let runner = fileRunner else { + throw ADSBDataSourceError.initializationFailed + } + + // Start background file processing + processingTask = Task { + do { + runner.openFile() + runner.readFile() + + // Start decoding in background + await runner.decodeFromFile() + + // Start timer for data consumption + await startDataConsumption(runner: runner, rate: config.processRate) + } catch { + await MainActor.run { + dataSubject.send(.error(error)) + stateSubject.send(.error(error.localizedDescription)) + } + } + } + } + + private func startDataConsumption(runner: ADSBFileRunner, rate: Int) async { + await MainActor.run { + timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + self?.processDataBatch(from: runner, batchSize: rate) + } + } + } + + private func processDataBatch(from runner: ADSBFileRunner, batchSize: Int) { + guard runner.jobDone() && runner.getCount() > 0 else { return } + + let batchSize = min(batchSize, runner.getCount()) + let data = runner.getPlainData(batchSize) + + Task { + for _ in 0..<data.getCount() { + let nextTag = data.getNextTag() + + switch nextTag { + case .ADSB_ICAO: + let icao = data.getIcaoName() + await tracker.updateIdentification(address: icao.address, icaoName: icao.ICAOname) + dataSubject.send(.aircraftIdentification(address: icao.address, icaoName: icao.ICAOname)) + + case .ADSB_LOCATION: + let location = data.getLocation() + await tracker.updatePosition(address: location.address, + latitude: location.lat, + longitude: location.long) + dataSubject.send(.aircraftPosition(address: location.address, + latitude: location.lat, + longitude: location.long)) + + case .ADSB_ALTITUDE: + let altitude = data.getAltitude() + await tracker.updateAltitude(address: altitude.address, altitude: altitude.altitude) + dataSubject.send(.aircraftAltitude(address: altitude.address, altitude: altitude.altitude)) + + case .EMPTY: + break + } + } + } + } +} + +// MARK: - Errors +enum ADSBDataSourceError: Error, LocalizedError { + case invalidConfiguration + case fileNotFound(String) + case initializationFailed + case processingError(Error) + + var errorDescription: String? { + switch self { + case .invalidConfiguration: + return "Invalid data source configuration" + case .fileNotFound(let path): + return "File not found at path: \(path)" + case .initializationFailed: + return "Failed to initialize data source" + case .processingError(let error): + return "Processing error: \(error.localizedDescription)" + } + } +}
\ No newline at end of file diff --git a/LearnMapKit/DataSources/NetworkADSBDataSource.swift b/LearnMapKit/DataSources/NetworkADSBDataSource.swift new file mode 100644 index 0000000..a2c3890 --- /dev/null +++ b/LearnMapKit/DataSources/NetworkADSBDataSource.swift @@ -0,0 +1,201 @@ +// +// NetworkADSBDataSource.swift +// LearnMapKit +// +// Network-based ADSB data source implementation +// + +import Foundation +import Combine + +@MainActor +class NetworkADSBDataSource: ADSBDataSource { + + // MARK: - ADSBDataSource Protocol + var dataStream: AnyPublisher<ADSBDataEvent, Never> { + dataSubject.eraseToAnyPublisher() + } + + var connectionState: AnyPublisher<DataSourceState, Never> { + stateSubject.eraseToAnyPublisher() + } + + private(set) var configuration: DataSourceConfiguration + + // MARK: - Private Properties + private let dataSubject = PassthroughSubject<ADSBDataEvent, Never>() + private let stateSubject = CurrentValueSubject<DataSourceState, Never>(.idle) + + private var netRunner: ADSBNetRunner? + private var processingTask: Task<Void, Error>? + private var timer: Timer? + private var reconnectAttempts = 0 + + // MARK: - Dependencies + private let decoder: ADSBMessageDecoder + private let tracker: AircraftTracker + + init(configuration: NetworkDataSourceConfiguration, + decoder: ADSBMessageDecoder = DefaultADSBMessageDecoder(), + tracker: AircraftTracker = DefaultAircraftTracker()) { + self.configuration = configuration + self.decoder = decoder + self.tracker = tracker + } + + // MARK: - ADSBDataSource Implementation + func configure(with config: DataSourceConfiguration) async { + guard let networkConfig = config as? NetworkDataSourceConfiguration else { + stateSubject.send(.error("Invalid configuration type for NetworkADSBDataSource")) + return + } + + stateSubject.send(.configuring) + self.configuration = networkConfig + reconnectAttempts = 0 + stateSubject.send(.idle) + } + + func start() async throws { + guard let networkConfig = configuration as? NetworkDataSourceConfiguration else { + throw ADSBDataSourceError.invalidConfiguration + } + + stateSubject.send(.connecting) + + do { + // Initialize network runner + netRunner = ADSBNetRunner(address: networkConfig.hostname, port: networkConfig.port) + + // Start network processing + try await startNetworkProcessing() + + stateSubject.send(.connected) + reconnectAttempts = 0 + } catch { + stateSubject.send(.error(error.localizedDescription)) + await handleConnectionError(error: error) + throw error + } + } + + func stop() async { + timer?.invalidate() + timer = nil + + processingTask?.cancel() + processingTask = nil + + await netRunner?.stop() + netRunner = nil + stateSubject.send(.disconnected) + reconnectAttempts = 0 + } + + func reconnect() async throws { + guard let networkConfig = configuration as? NetworkDataSourceConfiguration else { + throw ADSBDataSourceError.invalidConfiguration + } + + // Check if we should attempt reconnection + guard reconnectAttempts < networkConfig.reconnectAttempts else { + stateSubject.send(.error("Maximum reconnection attempts reached")) + return + } + + reconnectAttempts += 1 + await stop() + + // Wait before reconnecting + try await Task.sleep(nanoseconds: UInt64(2_000_000_000 * reconnectAttempts)) // Exponential backoff + + try await start() + } + + // MARK: - Private Methods + private func startNetworkProcessing() async throws { + guard let runner = netRunner else { + throw ADSBDataSourceError.initializationFailed + } + + // Start network connection + processingTask = Task { + do { + try await runner.start() + await startDataConsumption(runner: runner) + } catch { + await MainActor.run { + dataSubject.send(.error(error)) + stateSubject.send(.error(error.localizedDescription)) + } + } + } + } + + private func startDataConsumption(runner: ADSBNetRunner) async { + await MainActor.run { + timer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in + self?.processDataBatch(from: runner) + } + } + } + + private func processDataBatch(from runner: ADSBNetRunner) { + guard runner.getCount() > 0 else { return } + + Task { + for _ in 0..<runner.adsb_tag_stream.getCount() { + let nextTag = runner.adsb_tag_stream.getNextTag() + + switch nextTag { + case .ADSB_ICAO: + let icao = runner.adsb_tag_stream.getIcaoName() + await tracker.updateIdentification(address: icao.address, icaoName: icao.ICAOname) + dataSubject.send(.aircraftIdentification(address: icao.address, icaoName: icao.ICAOname)) + + case .ADSB_LOCATION: + let location = runner.adsb_tag_stream.getLocation() + await tracker.updatePosition(address: location.address, + latitude: location.lat, + longitude: location.long) + dataSubject.send(.aircraftPosition(address: location.address, + latitude: location.lat, + longitude: location.long)) + + case .ADSB_ALTITUDE: + let altitude = runner.adsb_tag_stream.getAltitude() + await tracker.updateAltitude(address: altitude.address, altitude: altitude.altitude) + dataSubject.send(.aircraftAltitude(address: altitude.address, altitude: altitude.altitude)) + + case .EMPTY: + break + } + } + } + } + + private func handleConnectionError(error: Error) async { + guard let networkConfig = configuration as? NetworkDataSourceConfiguration else { return } + + // Attempt automatic reconnection for network errors + if reconnectAttempts < networkConfig.reconnectAttempts { + Task { + do { + try await reconnect() + } catch { + // Final failure after all attempts + stateSubject.send(.error("Connection failed after \(networkConfig.reconnectAttempts) attempts")) + } + } + } + } +} + +// MARK: - Extensions +extension ADSBNetRunner { + func stop() async { + // Add proper cleanup method to ADSBNetRunner + timer?.invalidate() + timer = nil + } +}
\ No newline at end of file diff --git a/LearnMapKit/Managers/ConfigurationManager.swift b/LearnMapKit/Managers/ConfigurationManager.swift new file mode 100644 index 0000000..15ae274 --- /dev/null +++ b/LearnMapKit/Managers/ConfigurationManager.swift @@ -0,0 +1,322 @@ +// +// ConfigurationManager.swift +// LearnMapKit +// +// Centralized configuration management +// + +import Foundation +import Combine +import MapKit + +// MARK: - App Configuration +struct AppConfiguration: Equatable { + let dataSource: DataSourceConfiguration + let displaySettings: DisplaySettings + let mapSettings: MapSettings + let networkSettings: NetworkSettings + + static let `default` = AppConfiguration( + dataSource: NetworkDataSourceConfiguration( + hostname: "64.226.23.78", + port: 30003 + ), + displaySettings: DisplaySettings(), + mapSettings: MapSettings(), + networkSettings: NetworkSettings() + ) +} + +// MARK: - Display Settings +struct DisplaySettings: Equatable { + var showAircraftLabels: Bool = true + var showAltitude: Bool = true + var showSpeed: Bool = false + var aircraftHistoryLength: Int = 10 + var updateInterval: TimeInterval = 1.0 + var theme: AppTheme = .system + + enum AppTheme: String, CaseIterable { + case light, dark, system + } +} + +// MARK: - Map Settings +struct MapSettings: Equatable { + var defaultCenter: CLLocationCoordinate2D = CLLocationCoordinate2D( + latitude: 55.90159, + longitude: -3.53154 + ) + var defaultSpan: MKCoordinateSpan = MKCoordinateSpan( + latitudeDelta: 0.1, + longitudeDelta: 0.1 + ) + var mapStyle: MapStyle = .hybrid + var showTerrain: Bool = true + + enum MapStyle: String, CaseIterable { + case standard, hybrid, satellite + + var mkMapType: MKMapType { + switch self { + case .standard: return .standard + case .hybrid: return .hybrid + case .satellite: return .satellite + } + } + } + + // Custom equality to handle CLLocationCoordinate2D + static func == (lhs: MapSettings, rhs: MapSettings) -> Bool { + lhs.defaultCenter.latitude == rhs.defaultCenter.latitude && + lhs.defaultCenter.longitude == rhs.defaultCenter.longitude && + lhs.defaultSpan.latitudeDelta == rhs.defaultSpan.latitudeDelta && + lhs.defaultSpan.longitudeDelta == rhs.defaultSpan.longitudeDelta && + lhs.mapStyle == rhs.mapStyle && + lhs.showTerrain == rhs.showTerrain + } +} + +// MARK: - Network Settings +struct NetworkSettings: Equatable { + var connectionTimeout: TimeInterval = 10.0 + var reconnectAttempts: Int = 3 + var bufferSize: Int = 1024 + var enableHeartbeat: Bool = true + var heartbeatInterval: TimeInterval = 30.0 +} + +// MARK: - Configuration Manager Protocol +protocol ConfigurationManager: AnyObject { + var currentConfiguration: AnyPublisher<AppConfiguration, Never> { get } + var hasUnsavedChanges: AnyPublisher<Bool, Never> { get } + + func getCurrentConfiguration() -> AppConfiguration + func updateConfiguration(_ configuration: AppConfiguration) async + func updateDataSource(_ dataSource: DataSourceConfiguration) async + func updateDisplaySettings(_ settings: DisplaySettings) async + func updateMapSettings(_ settings: MapSettings) async + func updateNetworkSettings(_ settings: NetworkSettings) async + func resetToDefaults() async + func saveConfiguration() async throws + func validateConfiguration(_ configuration: AppConfiguration) -> ConfigurationValidationResult +} + +// MARK: - Validation +struct ConfigurationValidationResult { + let isValid: Bool + let errors: [String] + let warnings: [String] + + static let valid = ConfigurationValidationResult(isValid: true, errors: [], warnings: []) +} + +// MARK: - Default Implementation +@MainActor +class DefaultConfigurationManager: ConfigurationManager { + + // MARK: - Published Properties + var currentConfiguration: AnyPublisher<AppConfiguration, Never> { + configurationSubject.eraseToAnyPublisher() + } + + var hasUnsavedChanges: AnyPublisher<Bool, Never> { + hasChangesSubject.eraseToAnyPublisher() + } + + // MARK: - Private Properties + private let configurationSubject: CurrentValueSubject<AppConfiguration, Never> + private let hasChangesSubject = CurrentValueSubject<Bool, Never>(false) + private var savedConfiguration: AppConfiguration + private let userDefaults = UserDefaults.standard + + // MARK: - Keys for UserDefaults + private enum Keys { + static let configuration = "app_configuration" + } + + init() { + // Load configuration from UserDefaults or use default + if let data = userDefaults.data(forKey: Keys.configuration), + let configuration = try? JSONDecoder().decode(AppConfiguration.self, from: data) { + self.savedConfiguration = configuration + } else { + self.savedConfiguration = AppConfiguration.default + } + + self.configurationSubject = CurrentValueSubject(savedConfiguration) + + // Monitor for changes + setupChangeMonitoring() + } + + // MARK: - ConfigurationManager Implementation + func getCurrentConfiguration() -> AppConfiguration { + configurationSubject.value + } + + func updateConfiguration(_ configuration: AppConfiguration) async { + configurationSubject.send(configuration) + checkForChanges() + } + + func updateDataSource(_ dataSource: DataSourceConfiguration) async { + var current = getCurrentConfiguration() + current = AppConfiguration( + dataSource: dataSource, + displaySettings: current.displaySettings, + mapSettings: current.mapSettings, + networkSettings: current.networkSettings + ) + await updateConfiguration(current) + } + + func updateDisplaySettings(_ settings: DisplaySettings) async { + var current = getCurrentConfiguration() + current = AppConfiguration( + dataSource: current.dataSource, + displaySettings: settings, + mapSettings: current.mapSettings, + networkSettings: current.networkSettings + ) + await updateConfiguration(current) + } + + func updateMapSettings(_ settings: MapSettings) async { + var current = getCurrentConfiguration() + current = AppConfiguration( + dataSource: current.dataSource, + displaySettings: current.displaySettings, + mapSettings: settings, + networkSettings: current.networkSettings + ) + await updateConfiguration(current) + } + + func updateNetworkSettings(_ settings: NetworkSettings) async { + var current = getCurrentConfiguration() + current = AppConfiguration( + dataSource: current.dataSource, + displaySettings: current.displaySettings, + mapSettings: current.mapSettings, + networkSettings: settings + ) + await updateConfiguration(current) + } + + func resetToDefaults() async { + await updateConfiguration(AppConfiguration.default) + } + + func saveConfiguration() async throws { + let current = getCurrentConfiguration() + let data = try JSONEncoder().encode(current) + userDefaults.set(data, forKey: Keys.configuration) + savedConfiguration = current + hasChangesSubject.send(false) + } + + func validateConfiguration(_ configuration: AppConfiguration) -> ConfigurationValidationResult { + var errors: [String] = [] + var warnings: [String] = [] + + // Validate data source + switch configuration.dataSource.sourceType { + case .file(let path): + if !FileManager.default.fileExists(atPath: path) { + errors.append("File does not exist at path: \(path)") + } + case .network(let host, let port): + if host.isEmpty { + errors.append("Network hostname cannot be empty") + } + if port <= 0 || port > 65535 { + errors.append("Invalid port number: \(port)") + } + case .test: + warnings.append("Using test data source") + } + + // Validate display settings + if configuration.displaySettings.updateInterval < 0.1 { + errors.append("Update interval must be at least 0.1 seconds") + } + if configuration.displaySettings.aircraftHistoryLength < 1 { + errors.append("Aircraft history length must be at least 1") + } + + // Validate network settings + if configuration.networkSettings.connectionTimeout < 1.0 { + errors.append("Connection timeout must be at least 1 second") + } + if configuration.networkSettings.reconnectAttempts < 0 { + errors.append("Reconnect attempts cannot be negative") + } + + return ConfigurationValidationResult( + isValid: errors.isEmpty, + errors: errors, + warnings: warnings + ) + } + + // MARK: - Private Methods + private func setupChangeMonitoring() { + configurationSubject + .dropFirst() // Skip initial value + .sink { [weak self] _ in + self?.checkForChanges() + } + .store(in: &cancellables) + } + + private func checkForChanges() { + let hasChanges = getCurrentConfiguration() != savedConfiguration + hasChangesSubject.send(hasChanges) + } + + private var cancellables = Set<AnyCancellable>() +} + +// MARK: - Extensions for Codable Support +extension AppConfiguration: Codable { + // Custom coding keys and implementation would be needed here + // for proper serialization of the configuration +} + +extension DisplaySettings: Codable {} +extension NetworkSettings: Codable {} + +extension MapSettings: Codable { + enum CodingKeys: String, CodingKey { + case defaultCenterLatitude, defaultCenterLongitude + case defaultSpanLatitudeDelta, defaultSpanLongitudeDelta + case mapStyle, showTerrain + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let latitude = try container.decode(Double.self, forKey: .defaultCenterLatitude) + let longitude = try container.decode(Double.self, forKey: .defaultCenterLongitude) + defaultCenter = CLLocationCoordinate2D(latitude: latitude, longitude: longitude) + + let latDelta = try container.decode(Double.self, forKey: .defaultSpanLatitudeDelta) + let lonDelta = try container.decode(Double.self, forKey: .defaultSpanLongitudeDelta) + defaultSpan = MKCoordinateSpan(latitudeDelta: latDelta, longitudeDelta: lonDelta) + + mapStyle = try container.decode(MapStyle.self, forKey: .mapStyle) + showTerrain = try container.decode(Bool.self, forKey: .showTerrain) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(defaultCenter.latitude, forKey: .defaultCenterLatitude) + try container.encode(defaultCenter.longitude, forKey: .defaultCenterLongitude) + try container.encode(defaultSpan.latitudeDelta, forKey: .defaultSpanLatitudeDelta) + try container.encode(defaultSpan.longitudeDelta, forKey: .defaultSpanLongitudeDelta) + try container.encode(mapStyle, forKey: .mapStyle) + try container.encode(showTerrain, forKey: .showTerrain) + } +}
\ No newline at end of file diff --git a/LearnMapKit/Repositories/AircraftRepository.swift b/LearnMapKit/Repositories/AircraftRepository.swift new file mode 100644 index 0000000..6a6bc7e --- /dev/null +++ b/LearnMapKit/Repositories/AircraftRepository.swift @@ -0,0 +1,265 @@ +// +// AircraftRepository.swift +// LearnMapKit +// +// Aircraft data management and state tracking +// + +import Foundation +import Combine + +// MARK: - Aircraft Model +struct Aircraft: Identifiable, Equatable { + let id: Int // ICAO address + let icaoAddress: Int + let icaoName: String? + let position: Position? + let altitude: Int? + let lastSeen: Date + let flightInfo: FlightInfo? + + struct Position: Equatable { + let latitude: Double + let longitude: Double + let accuracy: PositionAccuracy + let timestamp: Date + + enum PositionAccuracy { + case unknown, low, medium, high + } + } + + struct FlightInfo { + let from: String? + let to: String? + let route: String? + } + + // Computed properties for UI + var isActive: Bool { + Date().timeIntervalSince(lastSeen) < 300 // 5 minutes + } + + var displayName: String { + icaoName ?? "Unknown (\(String(format: "%06X", icaoAddress)))" + } +} + +// MARK: - Aircraft Repository Protocol +protocol AircraftRepository: AnyObject { + var aircraftStream: AnyPublisher<[Aircraft], Never> { get } + var aircraftCount: AnyPublisher<Int, Never> { get } + + func getAllAircraft() -> [Aircraft] + func getAircraft(withId id: Int) -> Aircraft? + func updateAircraft(with event: ADSBDataEvent) + func removeInactiveAircraft() + func clearAll() +} + +// MARK: - Default Implementation +@MainActor +class DefaultAircraftRepository: AircraftRepository { + + // MARK: - Published Properties + var aircraftStream: AnyPublisher<[Aircraft], Never> { + aircraftSubject.eraseToAnyPublisher() + } + + var aircraftCount: AnyPublisher<Int, Never> { + aircraftSubject + .map { $0.count } + .removeDuplicates() + .eraseToAnyPublisher() + } + + // MARK: - Private Properties + private let aircraftSubject = CurrentValueSubject<[Aircraft], Never>([]) + private var aircraftCache: [Int: Aircraft] = [:] + + // Dependencies + private let tracker: AircraftTracker + + init(tracker: AircraftTracker = DefaultAircraftTracker()) { + self.tracker = tracker + startCleanupTimer() + } + + // MARK: - AircraftRepository Implementation + func getAllAircraft() -> [Aircraft] { + Array(aircraftCache.values) + .filter { $0.isActive } + .sorted { $0.lastSeen > $1.lastSeen } + } + + func getAircraft(withId id: Int) -> Aircraft? { + aircraftCache[id] + } + + func updateAircraft(with event: ADSBDataEvent) { + switch event { + case .aircraftIdentification(let address, let icaoName): + updateIdentification(address: address, icaoName: icaoName) + + case .aircraftPosition(let address, let latitude, let longitude): + updatePosition(address: address, latitude: latitude, longitude: longitude) + + case .aircraftAltitude(let address, let altitude): + updateAltitude(address: address, altitude: altitude) + + case .rawMessage, .error: + break // Handle these separately if needed + } + } + + func removeInactiveAircraft() { + let activeAircraft = aircraftCache.filter { _, aircraft in + aircraft.isActive + } + + if activeAircraft.count != aircraftCache.count { + aircraftCache = activeAircraft + publishUpdate() + } + } + + func clearAll() { + aircraftCache.removeAll() + publishUpdate() + } + + // MARK: - Private Update Methods + private func updateIdentification(address: Int, icaoName: String) { + let aircraft = getOrCreateAircraft(address: address) + let updated = Aircraft( + id: aircraft.id, + icaoAddress: aircraft.icaoAddress, + icaoName: icaoName, + position: aircraft.position, + altitude: aircraft.altitude, + lastSeen: Date(), + flightInfo: aircraft.flightInfo + ) + + aircraftCache[address] = updated + publishUpdate() + } + + private func updatePosition(address: Int, latitude: Double, longitude: Double) { + Task { + // Update the tracker for position calculation + await tracker.updatePosition(address: address, latitude: latitude, longitude: longitude) + + // Get calculated position from tracker + if let calculatedPosition = await tracker.getPosition(address: address) { + let aircraft = getOrCreateAircraft(address: address) + let position = Aircraft.Position( + latitude: calculatedPosition.0, + longitude: calculatedPosition.1, + accuracy: .medium, // TODO: Determine accuracy from tracker + timestamp: Date() + ) + + let updated = Aircraft( + id: aircraft.id, + icaoAddress: aircraft.icaoAddress, + icaoName: aircraft.icaoName, + position: position, + altitude: aircraft.altitude, + lastSeen: Date(), + flightInfo: aircraft.flightInfo + ) + + aircraftCache[address] = updated + publishUpdate() + } + } + } + + private func updateAltitude(address: Int, altitude: Int) { + let aircraft = getOrCreateAircraft(address: address) + let updated = Aircraft( + id: aircraft.id, + icaoAddress: aircraft.icaoAddress, + icaoName: aircraft.icaoName, + position: aircraft.position, + altitude: altitude, + lastSeen: Date(), + flightInfo: aircraft.flightInfo + ) + + aircraftCache[address] = updated + publishUpdate() + } + + private func getOrCreateAircraft(address: Int) -> Aircraft { + if let existing = aircraftCache[address] { + return existing + } + + let newAircraft = Aircraft( + id: address, + icaoAddress: address, + icaoName: nil, + position: nil, + altitude: nil, + lastSeen: Date(), + flightInfo: nil + ) + + return newAircraft + } + + private func publishUpdate() { + let currentAircraft = getAllAircraft() + aircraftSubject.send(currentAircraft) + } + + private func startCleanupTimer() { + Timer.scheduledTimer(withTimeInterval: 60.0, repeats: true) { [weak self] _ in + self?.removeInactiveAircraft() + } + } +} + +// MARK: - Aircraft Tracker Protocol +protocol AircraftTracker: Actor { + func updateIdentification(address: Int, icaoName: String) async + func updatePosition(address: Int, latitude: Double, longitude: Double) async + func updateAltitude(address: Int, altitude: Int) async + func getPosition(address: Int) async -> (Double, Double)? + func getAltitude(address: Int) async -> Int? + func getICAOName(address: Int) async -> String? +} + +// MARK: - Default Aircraft Tracker (Adapter for existing AirPlaneTracker) +actor DefaultAircraftTracker: AircraftTracker { + private let airplaneTracker = AirPlaneTracker() + + func updateIdentification(address: Int, icaoName: String) async { + airplaneTracker.addDF17Indentification(address, icaoName) + } + + func updatePosition(address: Int, latitude: Double, longitude: Double) async { + // Note: The original tracker expects CPR coordinates, not lat/long + // This is a simplified adapter - you might need to adjust based on your data flow + airplaneTracker.addDF17AirBornPosition(address, latitude, longitude, 0, true) + } + + func updateAltitude(address: Int, altitude: Int) async { + // Altitude is typically updated with position in the original tracker + // This might need adjustment based on your specific needs + } + + func getPosition(address: Int) async -> (Double, Double)? { + return airplaneTracker.getPosition(address) + } + + func getAltitude(address: Int) async -> Int? { + return airplaneTracker.getAltitude(address) + } + + func getICAOName(address: Int) async -> String? { + return airplaneTracker.getICAOname(address) + } +}
\ No newline at end of file diff --git a/LearnMapKit/ViewModels/AircraftTrackingViewModel.swift b/LearnMapKit/ViewModels/AircraftTrackingViewModel.swift new file mode 100644 index 0000000..048418f --- /dev/null +++ b/LearnMapKit/ViewModels/AircraftTrackingViewModel.swift @@ -0,0 +1,327 @@ +// +// 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<AnyCancellable>() + + // 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<ADSBDataEvent, Never> { + Empty().eraseToAnyPublisher() + } + + var connectionState: AnyPublisher<DataSourceState, Never> { + 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 + } + } +}
\ No newline at end of file |
