1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
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
}
}
}
|