Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions dashpilot-ios/dashpilot/UI/DashboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,14 @@ struct DashboardView: View {
@Environment(ConnectionViewModel.self) var connectionVM
@Environment(\.dismiss) private var dismiss

/// This view's own subscription, created once per appearance. The web
/// view's coordinator consumes it and cancels it when it is torn down.
@State private var dashStream: AsyncStream<DashState>?

var body: some View {
ZStack(alignment: .topLeading) {
Group {
if let dashStream = connectionVM.dashMessages {
if let dashStream {
switch dashboardType {
case DashboardType.web.rawValue:
WebDashView(url: dashboardUrl, incomingMessages: dashStream)
Expand Down Expand Up @@ -41,7 +45,12 @@ struct DashboardView: View {
.navigationBarHidden(true)
.statusBar(hidden: true)
.persistentSystemOverlays(.hidden)
.onAppear { UIApplication.shared.isIdleTimerDisabled = true }
.onAppear {
UIApplication.shared.isIdleTimerDisabled = true
if dashStream == nil {
dashStream = connectionVM.dashStateStream()
}
}
.onDisappear { UIApplication.shared.isIdleTimerDisabled = false }
}
}
18 changes: 10 additions & 8 deletions dashpilot-ios/dashpilot/UI/HomeView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,19 @@ struct HomeView: View {
.padding(DashMetrics.screenPadding)
}
.navigationBarHidden(true)
.task(id: connectionVM.streamGeneration) {
guard let stream = connectionVM.dashMessages else {
dash = nil
return
}
// The for-await loop ends cleanly when the task is cancelled
// (view disappears) or the stream finishes.
for await state in stream {
.task {
// Each appearance opens its own subscription (with the latest
// state replayed), so navigating away cancels only this view's
// stream — the pipeline keeps feeding the other views.
for await state in connectionVM.dashStateStream() {
dash = state
}
}
.onChange(of: connectionVM.connectionStatus) { _, status in
if status == .disconnected {
dash = nil
}
}
}

// MARK: - Header
Expand Down
55 changes: 39 additions & 16 deletions dashpilot-ios/dashpilot/ViewModel/ConnectionViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,19 @@ final class ConnectionViewModel {
var onboardingRequested = false

private(set) var connectionStatus: ConnectionStatus = .disconnected
private(set) var dashMessages: AsyncStream<DashState>?
private(set) var discoveredAddress: String?
private(set) var discoveryError: String?

/// Most recent state of the session, replayed to new subscribers so a view
/// opened mid-session renders immediately.
private(set) var latestDashState: DashState?

/// Live per-view subscriptions. AsyncStream is single-consumer and dies
/// with its consuming task, so every view gets its own stream and the pump
/// fans out to all of them (the Android equivalent is a shared Flow).
@ObservationIgnored
private var dashSubscribers: [UUID: AsyncStream<DashState>.Continuation] = [:]

/// Live BLE manager while a DashKit session is active. Views use it to
/// send control commands and the settings screen for firmware/OTA access.
private(set) var bleManager: DashKitBleManager?
Expand All @@ -29,14 +38,30 @@ final class ConnectionViewModel {
/// error so foregrounding retries the same source, like Android.
private(set) var activeSourceType: DataSourceType?

/// Bumped whenever `dashMessages` is replaced, so views can restart their
/// consuming task (`.task(id:)`) on reconnect.
private(set) var streamGeneration = 0

private var dataSource: (any IDataSource)?
private var connectTask: Task<Void, Never>?
private var startupDiscoveryStarted = false

/// Returns a stream of `DashState` updates for one view. Each caller gets
/// an independent stream, so one view leaving (its task being cancelled)
/// tears down only its own subscription — not the pipeline feeding the
/// other views. Subscriptions survive reconnects; the stream ends when the
/// consuming task is cancelled.
func dashStateStream() -> AsyncStream<DashState> {
AsyncStream(bufferingPolicy: .bufferingNewest(1)) { continuation in
let id = UUID()
dashSubscribers[id] = continuation
if let latest = latestDashState {
continuation.yield(latest)
}
continuation.onTermination = { [weak self] _ in
Task { @MainActor in
self?.dashSubscribers.removeValue(forKey: id)
}
}
}
}

/// True when a new connection may start (mirrors Android: connect is
/// allowed from Disconnected and Error).
private var canStartConnection: Bool {
Expand Down Expand Up @@ -193,7 +218,7 @@ final class ConnectionViewModel {
bleManager?.disconnect()
bleManager = nil
activeSourceType = nil
dashMessages = nil
latestDashState = nil
discoveredAddress = nil
discoveryError = nil
connectionStatus = .disconnected
Expand All @@ -220,15 +245,11 @@ final class ConnectionViewModel {
startStreaming(ds, resyncDashKit: false)
}

/// Consumes the data source's `CarState` stream and republishes it as
/// `DashState` for the UI; flips to `.connected` on the first message.
/// Consumes the data source's `CarState` stream, republishes it as
/// `DashState` to every subscribed view, and flips to `.connected` on the
/// first message. Subscribers are not finished when a session ends — a
/// reconnect's pump simply resumes feeding them.
private func startStreaming(_ ds: any IDataSource, resyncDashKit: Bool) {
var capturedContinuation: AsyncStream<DashState>.Continuation?
dashMessages = AsyncStream<DashState>(bufferingPolicy: .bufferingNewest(1)) { continuation in
capturedContinuation = continuation
}
streamGeneration += 1

connectTask = Task { @MainActor [weak self] in
guard let self else { return }
UIDevice.current.isBatteryMonitoringEnabled = true
Expand All @@ -252,9 +273,11 @@ final class ConnectionViewModel {
phoneBattery: batteryLevel >= 0 ? Int(batteryLevel * 100) : -1,
currentTime: Int64(Date().timeIntervalSince1970 * 1000)
)
capturedContinuation?.yield(state)
self.latestDashState = state
for continuation in self.dashSubscribers.values {
continuation.yield(state)
}
}
capturedContinuation?.finish()
}
}
}
Loading