Compare commits

...
Author SHA1 Message Date
fototeddyandClaude Sonnet 5 0fdef337fc Simplify CarPlay Live Activity to letter+arrow glance indicator
Replaces the full sentence ("Heck steht höher, links steht höher") in
the .small activity family with a compact "H ↑ L" style readout: a
direction letter per axis (only shown when outside tolerance) around a
single up arrow, since only the higher side is ever named anyway. Text
now shares the same green/orange/red scale as the bubble glyph (factored
into one levelColour helper instead of two duplicated thresholds), and
uses a scalable font style with minimumScaleFactor so it fits whatever
size CarPlay actually grants the card - there's no API to query the
physical display size from Live Activity content.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 14:08:40 +02:00
fototeddyandClaude Sonnet 5 1b06ac36d1 Debounce Live Activity ending on BLE disconnect, auto-resume on reconnect
A dropped BLE connection ended the Live Activity immediately, which
flickered off and on during the brief, routine disconnects that happen
constantly while driving. It now waits 12s before actually ending, and
if the sensor reconnects within that window the pending end is simply
cancelled. If the disconnect does outlast the grace period and the
activity really ends, reconnecting afterwards restarts it automatically
with the last known reading, since that wasn't a deliberate user stop.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 12:00:49 +02:00
fototeddyandClaude Sonnet 5 46a4da5e37 Fix Live Activity in CarPlay: show real bubble and text via .small family
CarPlay renders Live Activities through the .small activity family
(shared with the Watch Smart Stack), not the lock-screen content view.
Without it, CarPlay fell back to the Dynamic Island's compactLeading/
compactTrailing, which only had the generic "level" SF Symbol and an
empty trailing view — hence the wrong icon and missing text.

Adds .supplementalActivityFamilies([.small]) and a compact layout that
reuses the real LevelBubbleGlyph plus a one-line instruction for that
family, while the lock screen keeps its existing two-line view.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 11:37:50 +02:00
fototeddyandClaude Sonnet 5 45aa7cf984 Add auswählbare Fahrzeuggrafik am Neigungsmesser, neues California-Set
Jedes Grafikset (Seiten-/Heckansicht) ist jetzt ein VehicleGraphicStyle
mit eigenem Seitenverhältnis, wählbar in der Neigungsmesser-Einrichtung
und angewendet in Fahrzeug-Ansicht und Ausrichtungs-Assistent. Die
bisherigen VehicleSide/VehicleRear-Assets heißen jetzt VansterSide/
VansterRear, damit sie eindeutig ihrem Set zuzuordnen sind; California
kommt als zweites Set mit eigener Seitenansicht dazu (Heckansicht
vorerst von Vanster übernommen).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 11:06:35 +02:00
fototeddyandClaude Sonnet 5 f175372318 Vereinfache Fahrzeug-Symbolauswahl auf sechs Icons, Default suv.side
Ersetzt die zehn wählbaren Symbole durch eine kürzere, konsistentere
Auswahl (car, car.side, suv.side, truck.pickup.side, box.truck, bus)
und setzt suv.side als neuen Standard für neu angelegte Fahrzeuge.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 10:04:48 +02:00
23 changed files with 316 additions and 38 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

@@ -0,0 +1,10 @@
{
"images" : [
{
"filename" : "CaliforniaSide.png",
"idiom" : "universal"
}
],
"info" : { "author" : "xcode", "version" : 1 },
"properties" : { "template-rendering-intent" : "template" }
}
@@ -1,7 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "VehicleRear.png", "filename" : "VansterRear.png",
"idiom" : "universal" "idiom" : "universal"
} }
], ],

Before

Width:  |  Height:  |  Size: 130 KiB

After

Width:  |  Height:  |  Size: 130 KiB

@@ -1,7 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "VehicleSide.png", "filename" : "VansterSide.png",
"idiom" : "universal" "idiom" : "universal"
} }
], ],

Before

Width:  |  Height:  |  Size: 253 KiB

After

Width:  |  Height:  |  Size: 253 KiB

+18 -2
View File
@@ -897,7 +897,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 {
@@ -970,7 +980,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
@@ -5,14 +5,28 @@ import Observation
/// Startet, aktualisiert und beendet die Live Activity des Neigungsmessers. /// Startet, aktualisiert und beendet die Live Activity des Neigungsmessers.
/// ///
/// Es läuft höchstens eine Aktivität gleichzeitig mehr als einen /// Es läuft höchstens eine Aktivität gleichzeitig mehr als einen
/// Neigungsmesser gibt es im aktiven Profil ohnehin nicht. Seit iOS 17 zeigt /// Neigungsmesser gibt es im aktiven Profil ohnehin nicht. Seit iOS 26 zeigt
/// CarPlay eine laufende Live Activity automatisch im Dashboard an; ein /// CarPlay eine laufende Live Activity automatisch im Dashboard an; ein
/// eigenes CarPlay-App-Target braucht es dafür nicht. /// eigenes CarPlay-App-Target braucht es dafür nicht die Inhalte kommen
/// aber nicht von der Sperrbildschirm-Ansicht, sondern von der
/// `.small`-Aktivitätsfamilie (siehe `VanAligneiOSWidgetLiveActivity`).
@Observable @Observable
final class LevelActivityManager { 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.
@@ -23,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 {
@@ -44,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
@@ -35,7 +35,8 @@ struct AlignmentAssistantView: View {
LevelBubble(pitch: state.pitch, roll: state.roll) LevelBubble(pitch: state.pitch, roll: state.roll)
.frame(maxWidth: 320) .frame(maxWidth: 320)
case .vehicle: case .vehicle:
VehicleTiltView(pitch: state.pitch, roll: state.roll) VehicleTiltView(pitch: state.pitch, roll: state.roll,
style: device.vehicleGraphicStyle)
} }
if !isLive { disconnectedBanner } if !isLive { disconnectedBanner }
+40
View File
@@ -25,6 +25,8 @@ struct LevelSetupView: View {
store.devices.first { $0.id == device.id } ?? device store.devices.first { $0.id == device.id } ?? device
} }
private let graphicColumns = [GridItem(.adaptive(minimum: 76), spacing: 12)]
var body: some View { var body: some View {
List { List {
Section { Section {
@@ -80,6 +82,44 @@ struct LevelSetupView: View {
} footer: { } footer: {
Text(calibrationHint) Text(calibrationHint)
} }
Section {
LazyVGrid(columns: graphicColumns, spacing: 12) {
ForEach(VehicleGraphicStyle.allCases) { style in
Button {
var updated = currentDevice
updated.vehicleGraphicStyle = style
store.update(updated)
} label: {
VStack(spacing: 4) {
Image(style.sideImageName)
.resizable()
.scaledToFit()
.padding(10)
.frame(width: 60, height: 60)
.background(currentDevice.vehicleGraphicStyle == style
? Color.accentColor.opacity(0.18)
: Color.secondary.opacity(0.08),
in: .rect(cornerRadius: 12))
.foregroundStyle(currentDevice.vehicleGraphicStyle == style
? Color.accentColor : Color.primary)
Text(style.title)
.font(.caption2)
.foregroundStyle(currentDevice.vehicleGraphicStyle == style
? Color.accentColor : Color.secondary)
}
}
.buttonStyle(.plain)
.accessibilityLabel(style.title)
}
}
.padding(.vertical, 4)
} header: {
Text("Fahrzeuggrafik")
} footer: {
Text("Wird in der Fahrzeug-Darstellung des Neigungsmessers verwendet. "
+ "Weitere Grafiken folgen.")
}
} }
.navigationTitle("Neigungsmesser einrichten") .navigationTitle("Neigungsmesser einrichten")
.navigationBarTitleDisplayMode(.inline) .navigationBarTitleDisplayMode(.inline)
+2 -1
View File
@@ -127,7 +127,8 @@ struct LevelControls: View {
LevelBubble(pitch: state.pitch, roll: state.roll) LevelBubble(pitch: state.pitch, roll: state.roll)
.frame(maxHeight: 220) .frame(maxHeight: 220)
case .vehicle: case .vehicle:
VehicleTiltView(pitch: state.pitch, roll: state.roll) VehicleTiltView(pitch: state.pitch, roll: state.roll,
style: device.vehicleGraphicStyle)
} }
if let instruction = state.instruction { if let instruction = state.instruction {
+1 -1
View File
@@ -145,7 +145,7 @@ private struct ProfileEditView: View {
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
@State private var name = "" @State private var name = ""
@State private var symbol = "box.truck" @State private var symbol = "suv.side"
@State private var trackWidth = "" @State private var trackWidth = ""
@State private var wheelbase = "" @State private var wheelbase = ""
+5 -4
View File
@@ -15,22 +15,23 @@ import SwiftUI
struct VehicleTiltView: View { struct VehicleTiltView: View {
let pitch: Double? let pitch: Double?
let roll: Double? let roll: Double?
var style: VehicleGraphicStyle = .vanster
var body: some View { var body: some View {
VStack(spacing: 20) { VStack(spacing: 20) {
tiltPanel(image: "VehicleSide", tiltPanel(image: style.sideImageName,
angle: pitch, angle: pitch,
title: "Längs", title: "Längs",
lowerLabel: "Front", lowerLabel: "Front",
upperLabel: "Heck", upperLabel: "Heck",
aspect: VehicleTilt.sideAspect) aspect: style.sideAspect)
tiltPanel(image: "VehicleRear", tiltPanel(image: style.rearImageName,
angle: roll, angle: roll,
title: "Quer", title: "Quer",
lowerLabel: "links", lowerLabel: "links",
upperLabel: "rechts", upperLabel: "rechts",
aspect: VehicleTilt.rearAspect) aspect: style.rearAspect)
// Ohne diesen Hinweis nähme man den Bildwinkel für den echten. // Ohne diesen Hinweis nähme man den Bildwinkel für den echten.
Text(String(format: "Neigung %.0f-fach überhöht dargestellt " Text(String(format: "Neigung %.0f-fach überhöht dargestellt "
@@ -1,7 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "VehicleRear.png", "filename" : "VansterRear.png",
"idiom" : "universal" "idiom" : "universal"
} }
], ],

Before

Width:  |  Height:  |  Size: 23 KiB

After

Width:  |  Height:  |  Size: 23 KiB

@@ -1,7 +1,7 @@
{ {
"images" : [ "images" : [
{ {
"filename" : "VehicleSide.png", "filename" : "VansterSide.png",
"idiom" : "universal" "idiom" : "universal"
} }
], ],

Before

Width:  |  Height:  |  Size: 51 KiB

After

Width:  |  Height:  |  Size: 51 KiB

@@ -14,9 +14,9 @@ struct WatchVehicleView: View {
/// Die angeschriebenen Gradzahlen bleiben echt. /// Die angeschriebenen Gradzahlen bleiben echt.
var body: some View { var body: some View {
VStack(spacing: 8) { VStack(spacing: 8) {
panel(image: "VehicleSide", angle: pitch, title: "Längs", panel(image: "VansterSide", angle: pitch, title: "Längs",
lower: "Front", upper: "Heck", aspect: VehicleTilt.sideAspect) lower: "Front", upper: "Heck", aspect: VehicleTilt.sideAspect)
panel(image: "VehicleRear", angle: roll, title: "Quer", panel(image: "VansterRear", angle: roll, title: "Quer",
lower: "links", upper: "rechts", aspect: VehicleTilt.rearAspect) lower: "links", upper: "rechts", aspect: VehicleTilt.rearAspect)
} }
} }
+4
View File
@@ -105,6 +105,8 @@ struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable {
//var fridgeZoneMode: FridgeZoneMode = .automatic //var fridgeZoneMode: FridgeZoneMode = .automatic
/// Nur für den Neigungsmesser: wie er im Fahrzeug sitzt. /// Nur für den Neigungsmesser: wie er im Fahrzeug sitzt.
var sensorOrientation = SensorOrientation.identity var sensorOrientation = SensorOrientation.identity
/// Nur für den Neigungsmesser: welche Fahrzeuggrafik die Fahrzeug-Ansicht zeigt.
var vehicleGraphicStyle = VehicleGraphicStyle.vanster
init(id: UUID = UUID(), init(id: UUID = UUID(),
name: String, name: String,
@@ -135,5 +137,7 @@ struct ConfiguredDevice: Identifiable, Codable, Hashable, Sendable {
// ?? .automatic // ?? .automatic
sensorOrientation = try container.decodeIfPresent(SensorOrientation.self, sensorOrientation = try container.decodeIfPresent(SensorOrientation.self,
forKey: .sensorOrientation) ?? .identity forKey: .sensorOrientation) ?? .identity
vehicleGraphicStyle = try container.decodeIfPresent(VehicleGraphicStyle.self,
forKey: .vehicleGraphicStyle) ?? .vanster
} }
} }
+2 -3
View File
@@ -15,7 +15,7 @@ struct Profile: Identifiable, Codable, Hashable, Sendable {
init(id: UUID = UUID(), init(id: UUID = UUID(),
name: String, name: String,
symbol: String = "box.truck", symbol: String = "suv.side",
trackWidth: Double? = nil, trackWidth: Double? = nil,
wheelbase: Double? = nil) { wheelbase: Double? = nil) {
self.id = id self.id = id
@@ -47,7 +47,6 @@ struct Profile: Identifiable, Codable, Hashable, Sendable {
/// Alle Namen gegen NSImage(systemSymbolName:) geprüft ein nicht /// Alle Namen gegen NSImage(systemSymbolName:) geprüft ein nicht
/// existierendes Symbol lässt SwiftUI stillschweigend auf Text zurückfallen. /// existierendes Symbol lässt SwiftUI stillschweigend auf Text zurückfallen.
static let symbols = [ static let symbols = [
"box.truck", "truck.pickup.side", "bus", "bus.doubledecker", "car", "car.side", "suv.side", "truck.pickup.side", "box.truck", "bus",
"car", "car.side", "tent", "sailboat", "house.lodge", "mountain.2",
] ]
} }
+52
View File
@@ -0,0 +1,52 @@
import Foundation
/// Welche Silhouette die Fahrzeug-Ansicht des Neigungsmessers zeigt.
///
/// Jeder Fall ist ein zusammengehöriges Bilderset aus Seiten- und
/// Heckansicht in Assets.xcassets, beide als Vorlage ("template")
/// exportiert, damit `foregroundStyle` sie einfärben kann. Im Picker wird
/// nur die Seitenansicht gezeigt.
enum VehicleGraphicStyle: String, CaseIterable, Identifiable, Codable, Sendable {
case vanster
case california
var id: String { rawValue }
var title: String {
switch self {
case .vanster: return "Vanster"
case .california: return "California"
}
}
var sideImageName: String {
switch self {
case .vanster: return "VansterSide"
case .california: return "CaliforniaSide"
}
}
/// Für California liegt noch keine eigene Heckansicht vor, deshalb
/// vorerst die von Vanster.
var rearImageName: String {
switch self {
case .vanster, .california: return "VansterRear"
}
}
/// Seitenverhältnis der jeweiligen Bilder ohne das würde eine Grafik
/// mit anderen Proportionen in der gemeinsamen Bildhülle verzerrt oder
/// ungewollt verkleinert erscheinen.
var sideAspect: Double {
switch self {
case .vanster: return VehicleTilt.sideAspect
case .california: return 1
}
}
var rearAspect: Double {
switch self {
case .vanster, .california: return VehicleTilt.rearAspect
}
}
}
+7 -1
View File
@@ -32,5 +32,11 @@ gefüllte Form, in der die Details erhalten bleiben.
```bash ```bash
swiftc -O -o /tmp/make-vehicle-art Tools/make-vehicle-art.swift swiftc -O -o /tmp/make-vehicle-art Tools/make-vehicle-art.swift
sips --resampleHeight 600 heckansicht.png --out /tmp/rear.png sips --resampleHeight 600 heckansicht.png --out /tmp/rear.png
/tmp/make-vehicle-art /tmp/rear.png CamperMonitor/Assets.xcassets/VehicleRear.imageset/VehicleRear.png /tmp/make-vehicle-art /tmp/rear.png CamperMonitor/Assets.xcassets/VansterRear.imageset/VansterRear.png
``` ```
Jedes Fahrzeug-Grafikset (siehe `VehicleGraphicStyle`) hat sein eigenes
Bildpaar `<Name>Side`/`<Name>Rear`. Hat die Vorlage schon einen
transparenten Hintergrund, geht die Quelldatei direkt ins Skript. Liegt sie
auf weißem Grund, muss der Rand vorher weg sonst wird die ganze Fläche
mitgefärbt.
@@ -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()
@@ -81,7 +90,7 @@ private struct LevelBubbleGlyph: View {
struct VanAligneiOSWidgetLiveActivity: Widget { struct VanAligneiOSWidgetLiveActivity: Widget {
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
ActivityConfiguration(for: LevelActivityAttributes.self) { context in ActivityConfiguration(for: LevelActivityAttributes.self) { context in
LockScreenLevelView(attributes: context.attributes, state: context.state) LevelActivityContent(attributes: context.attributes, state: context.state)
.activityBackgroundTint(Color(white: 0.08)) .activityBackgroundTint(Color(white: 0.08))
.activitySystemActionForegroundColor(.white) .activitySystemActionForegroundColor(.white)
@@ -109,30 +118,114 @@ struct VanAligneiOSWidgetLiveActivity: Widget {
} }
.keylineTint(context.state.isLevel ? .green : .orange) .keylineTint(context.state.isLevel ? .green : .orange)
} }
.supplementalActivityFamilies([.small])
} }
} }
/// Sperrbildschirm- und Banner-Ansicht. Dieselbe Ansicht landet automatisch /// Wählt je nach Darstellungsgrösse zwischen Sperrbildschirm- und
/// auch im CarPlay-Dashboard, sobald das iPhone verbunden ist ein eigenes /// Kompaktansicht.
/// CarPlay-App-Target ist dafür nicht nötig. ///
/// Ohne `.small`-Familie fällt CarPlay (wie die Smart Stack der Uhr) auf
/// `compactLeading`/`compactTrailing` der Dynamic Island zurück dort steht
/// nur das generische Symbol und, weil `compactTrailing` leer bleibt, gar
/// kein Text. Erst die `.small`-Familie liefert CarPlay Libelle und Text.
private struct LevelActivityContent: View {
let attributes: LevelActivityAttributes
let state: LevelActivityAttributes.ContentState
@Environment(\.activityFamily) private var activityFamily
var body: some View {
switch activityFamily {
case .small:
CompactLevelView(attributes: attributes, state: state)
default:
LockScreenLevelView(attributes: attributes, state: state)
}
}
}
/// Kompaktform für CarPlay-Dashboard und Smart Stack der Uhr: Libelle plus
/// ein Blick-Symbol statt eines ganzen Satzes zum Lesen im Vorbeifahren.
///
/// 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 {
let attributes: LevelActivityAttributes
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 {
HStack(spacing: 14) {
LevelBubbleGlyph(pitch: state.pitch, roll: state.roll, isLevel: state.isLevel, diameter: 40)
Group {
if !hasReading {
Text("")
} 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)
.foregroundStyle(statusColor)
Spacer(minLength: 0)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
}
}
/// Sperrbildschirm- und Banner-Ansicht.
private struct LockScreenLevelView: View { 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))"
} }