Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21f5d8de62 | ||
|
|
7dcb564cbf |
@@ -927,7 +927,17 @@ extension BluetoothManager: CBCentralManagerDelegate {
|
|||||||
queue: queue,
|
queue: queue,
|
||||||
onUpdate: { [weak self] snapshot in self?.record(snapshot) },
|
onUpdate: { [weak self] snapshot in self?.record(snapshot) },
|
||||||
onStateChange: { [weak self] state in
|
onStateChange: { [weak self] state in
|
||||||
self?.publish { self?.linkStates[device.id] = state }
|
self?.publish {
|
||||||
|
self?.linkStates[device.id] = state
|
||||||
|
if state == .live {
|
||||||
|
// Verbindung wieder da: ein anstehendes Ende
|
||||||
|
// verwerfen oder eine zwischenzeitlich doch schon
|
||||||
|
// beendete Live Activity wieder aufnehmen.
|
||||||
|
self?.activityManager?.handleReconnect(
|
||||||
|
deviceID: device.id,
|
||||||
|
state: self?.levelStates[device.id] ?? LevelState())
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onLevelState: { [weak self] state in
|
onLevelState: { [weak self] state in
|
||||||
self?.publish {
|
self?.publish {
|
||||||
@@ -1023,7 +1033,13 @@ extension BluetoothManager: CBCentralManagerDelegate {
|
|||||||
.map { Date().timeIntervalSince($0) } ?? 0
|
.map { Date().timeIntervalSince($0) } ?? 0
|
||||||
|
|
||||||
if let device = managed[peripheral.identifier] {
|
if let device = managed[peripheral.identifier] {
|
||||||
publish { self.linkStates[device.id] = .searching }
|
let deviceName = store.devices.first { $0.id == device.id }?.name ?? ""
|
||||||
|
publish {
|
||||||
|
self.linkStates[device.id] = .searching
|
||||||
|
// Erst nach einer Gnadenfrist wirklich beenden – sonst
|
||||||
|
// flackerte die Live Activity bei jedem kurzen Funkloch.
|
||||||
|
self.activityManager?.handleDisconnect(deviceID: device.id, deviceName: deviceName)
|
||||||
|
}
|
||||||
if lifetime >= stableConnection {
|
if lifetime >= stableConnection {
|
||||||
// Die Verbindung stand und ist weggefallen - im Fahrzeug der
|
// Die Verbindung stand und ist weggefallen - im Fahrzeug der
|
||||||
// Normalfall. Kurz durchatmen, dann wieder ran, sonst wäre das
|
// Normalfall. Kurz durchatmen, dann wieder ran, sonst wäre das
|
||||||
|
|||||||
@@ -15,6 +15,18 @@ final class LevelActivityManager {
|
|||||||
private(set) var trackedDeviceID: UUID?
|
private(set) var trackedDeviceID: UUID?
|
||||||
@ObservationIgnored private var activity: Activity<LevelActivityAttributes>?
|
@ObservationIgnored private var activity: Activity<LevelActivityAttributes>?
|
||||||
|
|
||||||
|
/// Wie lange nach einem Verbindungsabbruch gewartet wird, bevor die
|
||||||
|
/// Aktivität wirklich endet. Verbindungen fallen im Fahrzeug regelmässig
|
||||||
|
/// kurz weg – ohne diese Gnadenfrist flackerte die Anzeige bei jedem
|
||||||
|
/// kurzen Funkloch aus und wieder ein.
|
||||||
|
static let disconnectGrace: TimeInterval = 12
|
||||||
|
|
||||||
|
@ObservationIgnored private var pendingEnd: Task<Void, Never>?
|
||||||
|
/// Gerät, dessen Aktivität wegen einer länger anhaltenden Trennung
|
||||||
|
/// tatsächlich beendet wurde – bei der nächsten Wiederverbindung wird sie
|
||||||
|
/// automatisch neu gestartet, damit das kein bewusster Nutzer-Stop war.
|
||||||
|
@ObservationIgnored private var deviceToResume: (id: UUID, name: String)?
|
||||||
|
|
||||||
/// Ob gerade irgendeine Aktivität läuft – die App muss dafür im
|
/// Ob gerade irgendeine Aktivität läuft – die App muss dafür im
|
||||||
/// Hintergrund weiter nach dem Neigungsmesser funken, sonst friert die
|
/// Hintergrund weiter nach dem Neigungsmesser funken, sonst friert die
|
||||||
/// Anzeige beim ersten Sperren des Bildschirms ein.
|
/// Anzeige beim ersten Sperren des Bildschirms ein.
|
||||||
@@ -25,8 +37,11 @@ final class LevelActivityManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func start(deviceID: UUID, deviceName: String, state: LevelState) {
|
func start(deviceID: UUID, deviceName: String, state: LevelState) {
|
||||||
|
pendingEnd?.cancel()
|
||||||
|
pendingEnd = nil
|
||||||
|
deviceToResume = nil
|
||||||
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
|
guard ActivityAuthorizationInfo().areActivitiesEnabled else { return }
|
||||||
end()
|
endActivity()
|
||||||
let attributes = LevelActivityAttributes(deviceName: deviceName)
|
let attributes = LevelActivityAttributes(deviceName: deviceName)
|
||||||
let content = ActivityContent(state: Self.contentState(from: state), staleDate: nil)
|
let content = ActivityContent(state: Self.contentState(from: state), staleDate: nil)
|
||||||
do {
|
do {
|
||||||
@@ -46,7 +61,45 @@ final class LevelActivityManager {
|
|||||||
Task { await activity.update(content) }
|
Task { await activity.update(content) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bewusstes Beenden durch den Nutzer – anders als bei einem
|
||||||
|
/// Verbindungsabbruch soll das bei der nächsten Verbindung nicht
|
||||||
|
/// automatisch wieder aufleben.
|
||||||
func end() {
|
func end() {
|
||||||
|
pendingEnd?.cancel()
|
||||||
|
pendingEnd = nil
|
||||||
|
deviceToResume = nil
|
||||||
|
endActivity()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// BLE-Verbindung weg: nicht sofort beenden, sondern erst nach einer
|
||||||
|
/// kurzen Gnadenfrist – meldet sich das Gerät vorher zurück
|
||||||
|
/// (`handleReconnect`), passiert gar nichts.
|
||||||
|
func handleDisconnect(deviceID: UUID, deviceName: String) {
|
||||||
|
guard trackedDeviceID == deviceID, activity != nil, pendingEnd == nil else { return }
|
||||||
|
pendingEnd = Task { [weak self] in
|
||||||
|
try? await Task.sleep(for: .seconds(Self.disconnectGrace))
|
||||||
|
guard let self, !Task.isCancelled, self.trackedDeviceID == deviceID else { return }
|
||||||
|
self.deviceToResume = (deviceID, deviceName)
|
||||||
|
self.endActivity()
|
||||||
|
self.pendingEnd = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// BLE-Verbindung wieder da: ein noch anstehendes Ende verwerfen, oder –
|
||||||
|
/// falls die Gnadenfrist schon abgelaufen und die Aktivität wirklich
|
||||||
|
/// beendet war – sie mit dem letzten bekannten Stand neu starten.
|
||||||
|
func handleReconnect(deviceID: UUID, state: LevelState) {
|
||||||
|
if pendingEnd != nil, trackedDeviceID == deviceID {
|
||||||
|
pendingEnd?.cancel()
|
||||||
|
pendingEnd = nil
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let resume = deviceToResume, resume.id == deviceID else { return }
|
||||||
|
deviceToResume = nil
|
||||||
|
start(deviceID: deviceID, deviceName: resume.name, state: state)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func endActivity() {
|
||||||
guard let activity else { return }
|
guard let activity else { return }
|
||||||
trackedDeviceID = nil
|
trackedDeviceID = nil
|
||||||
self.activity = nil
|
self.activity = nil
|
||||||
|
|||||||
@@ -19,6 +19,20 @@ private struct LevelIcon: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Muss mit `LevelState.levelTolerance` übereinstimmen.
|
||||||
|
private let levelTolerance: Double = 0.5
|
||||||
|
|
||||||
|
/// Grün bis Toleranz, orange bis 2°, sonst rot – dieselbe Skala für Blase
|
||||||
|
/// und Text, wie in der App (`VehicleTilt.colour`). Hier dupliziert, weil
|
||||||
|
/// die Extension `Shared/` nicht mitkompiliert (siehe
|
||||||
|
/// `LevelActivityAttributes.swift`).
|
||||||
|
private func levelColour(forDeviation deviation: Double?) -> Color {
|
||||||
|
guard let deviation else { return .white.opacity(0.4) }
|
||||||
|
if deviation <= levelTolerance { return .green }
|
||||||
|
if deviation <= 2 { return .orange }
|
||||||
|
return .red
|
||||||
|
}
|
||||||
|
|
||||||
/// Verkleinerte Libelle für Sperrbildschirm/CarPlay und die erweiterte
|
/// Verkleinerte Libelle für Sperrbildschirm/CarPlay und die erweiterte
|
||||||
/// Dynamic Island – dieselbe Optik wie `LevelBubble` in der App, aber ohne
|
/// Dynamic Island – dieselbe Optik wie `LevelBubble` in der App, aber ohne
|
||||||
/// deren Abhängigkeit auf `Shared/Models/LevelState.swift`, das in der
|
/// deren Abhängigkeit auf `Shared/Models/LevelState.swift`, das in der
|
||||||
@@ -31,17 +45,12 @@ private struct LevelBubbleGlyph: View {
|
|||||||
|
|
||||||
/// Bis zu welcher Neigung die Blase ausschlägt, wie in der App-Libelle.
|
/// Bis zu welcher Neigung die Blase ausschlägt, wie in der App-Libelle.
|
||||||
private let range: Double = 6
|
private let range: Double = 6
|
||||||
/// Muss mit `LevelState.levelTolerance` übereinstimmen.
|
|
||||||
private let tolerance: Double = 0.5
|
|
||||||
|
|
||||||
private var hasReading: Bool { pitch != nil || roll != nil }
|
private var hasReading: Bool { pitch != nil || roll != nil }
|
||||||
|
|
||||||
private var bubbleColor: Color {
|
private var bubbleColor: Color {
|
||||||
guard let pitch, let roll else { return .white.opacity(0.4) }
|
guard let pitch, let roll else { return .white.opacity(0.4) }
|
||||||
let deviation = max(abs(pitch), abs(roll))
|
return levelColour(forDeviation: max(abs(pitch), abs(roll)))
|
||||||
if deviation <= tolerance { return .green }
|
|
||||||
if deviation <= 2 { return .orange }
|
|
||||||
return .red
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -50,7 +59,7 @@ private struct LevelBubbleGlyph: View {
|
|||||||
let travel = radius - bubble / 2 - 3
|
let travel = radius - bubble / 2 - 3
|
||||||
let offsetX = clamped(roll) / range * travel
|
let offsetX = clamped(roll) / range * travel
|
||||||
let offsetY = clamped(pitch) / range * travel
|
let offsetY = clamped(pitch) / range * travel
|
||||||
let toleranceRadius = max(tolerance / range * travel, bubble * 0.55)
|
let toleranceRadius = max(levelTolerance / range * travel, bubble * 0.55)
|
||||||
|
|
||||||
ZStack {
|
ZStack {
|
||||||
Circle()
|
Circle()
|
||||||
@@ -137,28 +146,67 @@ private struct LevelActivityContent: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Kompaktform für CarPlay-Dashboard und Smart Stack der Uhr: Libelle plus
|
/// Kompaktform für CarPlay-Dashboard und Smart Stack der Uhr: Libelle plus
|
||||||
/// eine Textzeile, ohne die längere Zwei-Zeilen-Aufschlüsselung des
|
/// ein Blick-Symbol statt eines ganzen Satzes – zum Lesen im Vorbeifahren.
|
||||||
/// Sperrbildschirms.
|
///
|
||||||
|
/// Gezeigt wird nur, welche Seite höher steht: Buchstabe (Längsneigung),
|
||||||
|
/// Pfeil, Buchstabe (Querneigung) – z.B. "H ↑ L" für "Heck und links stehen
|
||||||
|
/// höher". Ein einzelner Pfeil in der Mitte reicht, weil ohnehin nur die
|
||||||
|
/// höher stehende Seite benannt wird ("höher" ist implizit "nach oben").
|
||||||
private struct CompactLevelView: View {
|
private struct CompactLevelView: View {
|
||||||
let attributes: LevelActivityAttributes
|
let attributes: LevelActivityAttributes
|
||||||
let state: LevelActivityAttributes.ContentState
|
let state: LevelActivityAttributes.ContentState
|
||||||
|
|
||||||
|
private var hasReading: Bool { state.pitch != nil || state.roll != nil }
|
||||||
|
|
||||||
|
/// "H"/"F", nur wenn ausserhalb der Toleranz.
|
||||||
|
private var pitchLetter: String? {
|
||||||
|
guard let pitch = state.pitch, abs(pitch) > levelTolerance else { return nil }
|
||||||
|
return pitch >= 0 ? "H" : "F"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// "R"/"L", nur wenn ausserhalb der Toleranz.
|
||||||
|
private var rollLetter: String? {
|
||||||
|
guard let roll = state.roll, abs(roll) > levelTolerance else { return nil }
|
||||||
|
return roll >= 0 ? "R" : "L"
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dieselbe Skala wie die Blase, über die grössere der beiden Neigungen.
|
||||||
|
private var statusColor: Color {
|
||||||
|
guard let pitch = state.pitch, let roll = state.roll else { return .white.opacity(0.4) }
|
||||||
|
return levelColour(forDeviation: max(abs(pitch), abs(roll)))
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
HStack(spacing: 12) {
|
HStack(spacing: 14) {
|
||||||
LevelBubbleGlyph(pitch: state.pitch, roll: state.roll, isLevel: state.isLevel, diameter: 40)
|
LevelBubbleGlyph(pitch: state.pitch, roll: state.roll, isLevel: state.isLevel, diameter: 40)
|
||||||
|
|
||||||
Text(state.instruction
|
Group {
|
||||||
?? (state.pitch == nil && state.roll == nil ? "Keine Messwerte" : attributes.deviceName))
|
if !hasReading {
|
||||||
.font(.callout.weight(.semibold))
|
Text("–")
|
||||||
.foregroundStyle(state.isLevel ? .green : .white)
|
} else if state.isLevel {
|
||||||
|
Image(systemName: "checkmark.circle.fill")
|
||||||
|
} else {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if let pitchLetter { Text(pitchLetter) }
|
||||||
|
Image(systemName: "arrow.up")
|
||||||
|
if let rollLetter { Text(rollLetter) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Skalierbarer Textstil statt fester Punktgrösse: CarPlay-
|
||||||
|
// Bildschirme unterscheiden sich in Grösse, die exakten Masse
|
||||||
|
// eines Displays sind aber nicht abfragbar. `minimumScaleFactor`
|
||||||
|
// lässt die Schrift so gross wie möglich, aber so klein wie
|
||||||
|
// nötig werden, um in den vom System zugeteilten Platz zu passen.
|
||||||
|
.font(.title2.weight(.bold))
|
||||||
|
.minimumScaleFactor(0.5)
|
||||||
.lineLimit(1)
|
.lineLimit(1)
|
||||||
.minimumScaleFactor(0.8)
|
.foregroundStyle(statusColor)
|
||||||
|
|
||||||
Spacer(minLength: 0)
|
Spacer(minLength: 0)
|
||||||
}
|
}
|
||||||
.padding(.horizontal, 12)
|
.padding(.horizontal, 12)
|
||||||
.padding(.vertical, 8)
|
.padding(.vertical, 8)
|
||||||
.foregroundStyle(.white)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,20 +215,17 @@ private struct LockScreenLevelView: View {
|
|||||||
let attributes: LevelActivityAttributes
|
let attributes: LevelActivityAttributes
|
||||||
let state: LevelActivityAttributes.ContentState
|
let state: LevelActivityAttributes.ContentState
|
||||||
|
|
||||||
/// Muss mit `LevelState.levelTolerance` übereinstimmen.
|
|
||||||
private let tolerance: Double = 0.5
|
|
||||||
|
|
||||||
/// Längszeile, z.B. "Heck steht höher 1.8°" – nur wenn ausserhalb der
|
/// Längszeile, z.B. "Heck steht höher 1.8°" – nur wenn ausserhalb der
|
||||||
/// Toleranz, genau wie bei `LevelState.instruction` in der App.
|
/// Toleranz, genau wie bei `LevelState.instruction` in der App.
|
||||||
private var pitchLine: String? {
|
private var pitchLine: String? {
|
||||||
guard let pitch = state.pitch, abs(pitch) > tolerance else { return nil }
|
guard let pitch = state.pitch, abs(pitch) > levelTolerance else { return nil }
|
||||||
let label = pitch >= 0 ? "Heck steht höher" : "Front steht höher"
|
let label = pitch >= 0 ? "Heck steht höher" : "Front steht höher"
|
||||||
return "\(label) \(LevelDirectionFormatting.magnitude(pitch))"
|
return "\(label) \(LevelDirectionFormatting.magnitude(pitch))"
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Querzeile, z.B. "links steht höher 0.9°".
|
/// Querzeile, z.B. "links steht höher 0.9°".
|
||||||
private var rollLine: String? {
|
private var rollLine: String? {
|
||||||
guard let roll = state.roll, abs(roll) > tolerance else { return nil }
|
guard let roll = state.roll, abs(roll) > levelTolerance else { return nil }
|
||||||
let label = roll >= 0 ? "rechts steht höher" : "links steht höher"
|
let label = roll >= 0 ? "rechts steht höher" : "links steht höher"
|
||||||
return "\(label) \(LevelDirectionFormatting.magnitude(roll))"
|
return "\(label) \(LevelDirectionFormatting.magnitude(roll))"
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user