Compare commits

..
2 Commits
Author SHA1 Message Date
fototeddyandClaude Sonnet 5 26e088e48b Merge main: Live Activity CarPlay polish, direction-letter formatting, bubble pitch fix
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 01:05:38 +02:00
fototeddyandClaude Sonnet 5 4a8a40eea2 Minimize Dynamic Island, show direction letters instead of signs, fix inverted pitch bubble
- Dynamic Island now shows only the level icon (compact/minimal); the
  bubble, instruction text, and numbers move to the shared lock
  screen/CarPlay ActivityView, which iOS renders identically on both
  surfaces (no separate CarPlay layout API exists for this).
- Replace +/- signs with a leading direction letter (H/F for Heck-
  Front, R/L for rechts-links) via a new shared LevelDirectionFormatting
  helper, used by the level screen, alignment assistant, and dashboard
  card. The generic "Messwerte" list keeps raw signed values.
- Live Activity's instruction now reads as two lines ("Heck steht
  höher 1.8°" / "links steht höher 0.9°") instead of a sentence plus a
  separate number column.
- Fix the bubble level graphic's pitch axis being inverted (front/rear
  reversed) in all three copies (app, watch, Live Activity widget) —
  confirmed against the known-correct VehicleTiltView orientation.
- Dashboard card shows pitch and roll side by side, large, without the
  "Längsneigung"/"Querneigung" labels, for the leveling device.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-05 01:03:37 +02:00
6 changed files with 107 additions and 58 deletions
@@ -138,15 +138,15 @@ struct AlignmentAssistantView: View {
private var readings: some View { private var readings: some View {
HStack(spacing: 12) { HStack(spacing: 12) {
reading("Längs", state.pitch) reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
reading("Quer", state.roll) reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
reading("Gesamt", assistant.current?.deviation) reading("Gesamt", LevelDirectionFormatting.magnitude(assistant.current?.deviation))
} }
} }
private func reading(_ title: String, _ value: Double?) -> some View { private func reading(_ title: String, _ text: String) -> some View {
VStack(spacing: 4) { VStack(spacing: 4) {
Text(value.map { String(format: "%.1f°", $0) } ?? "") Text(text)
.font(.title2.weight(.semibold).monospacedDigit()) .font(.title2.weight(.semibold).monospacedDigit())
Text(title) Text(title)
.font(.caption) .font(.caption)
+17
View File
@@ -13,6 +13,8 @@ struct DeviceCard: View {
if isShowingLastSettings, let snapshot { if isShowingLastSettings, let snapshot {
lastSettings(for: snapshot) lastSettings(for: snapshot)
} else if let snapshot, snapshot.primaryMetric != nil, device.role == .leveling {
levelReadout(for: snapshot)
} else if let snapshot, let primary = snapshot.primaryMetric { } else if let snapshot, let primary = snapshot.primaryMetric {
HStack(alignment: .firstTextBaseline, spacing: 4) { HStack(alignment: .firstTextBaseline, spacing: 4) {
Text(primary.formatted) Text(primary.formatted)
@@ -115,6 +117,21 @@ struct DeviceCard: View {
} }
} }
/// Für die Nivellierung: Front/Heck und links/rechts nebeneinander, groß,
/// ohne "Längsneigung"/"Querneigung"-Beschriftung die Richtung steht ja
/// schon im vorangestellten Buchstaben.
private func levelReadout(for snapshot: DeviceSnapshot) -> some View {
let pitch = snapshot.metrics.first(where: { $0.key == "pitch" })?.value
let roll = snapshot.metrics.first(where: { $0.key == "roll" })?.value
return HStack(spacing: 24) {
Text(LevelDirectionFormatting.pitchTile(pitch))
Text(LevelDirectionFormatting.rollTile(roll))
}
.font(.system(size: 36, weight: .semibold, design: .rounded))
.contentTransition(.numericText())
.foregroundStyle(snapshot.isStale ? .secondary : .primary)
}
private func secondaryValues(for snapshot: DeviceSnapshot) -> some View { private func secondaryValues(for snapshot: DeviceSnapshot) -> some View {
let others = snapshot.metrics let others = snapshot.metrics
.filter { $0.id != snapshot.primaryMetric?.id && $0.value != nil } .filter { $0.id != snapshot.primaryMetric?.id && $0.value != nil }
+7 -6
View File
@@ -76,8 +76,9 @@ struct LevelBubble: View {
.fill(bubbleColor) .fill(bubbleColor)
.frame(width: bubble, height: bubble) .frame(width: bubble, height: bubble)
// Positiver Pitch heisst: das Heck steht höher, die Blase // Positiver Pitch heisst: das Heck steht höher, die Blase
// wandert also nach oben in der Ansicht nach hinten. // wandert also nach unten in der Ansicht nach hinten.
.offset(x: offsetX, y: -offsetY) // Muss zur "Fahrzeug"-Ansicht (VehicleTiltView) passen.
.offset(x: offsetX, y: offsetY)
.animation(.spring(duration: 0.35), value: offsetX) .animation(.spring(duration: 0.35), value: offsetX)
.animation(.spring(duration: 0.35), value: offsetY) .animation(.spring(duration: 0.35), value: offsetY)
.opacity(pitch == nil && roll == nil ? 0.25 : 1) .opacity(pitch == nil && roll == nil ? 0.25 : 1)
@@ -138,8 +139,8 @@ struct LevelControls: View {
} }
HStack(spacing: 24) { HStack(spacing: 24) {
reading("Längs", state.pitch) reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch))
reading("Quer", state.roll) reading("Quer", LevelDirectionFormatting.rollTile(state.roll))
} }
} }
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
@@ -239,9 +240,9 @@ struct LevelControls: View {
state.isKnownUncalibrated ? "Nullpunkt fehlt" : device.sensorOrientation.summary state.isKnownUncalibrated ? "Nullpunkt fehlt" : device.sensorOrientation.summary
} }
private func reading(_ title: String, _ value: Double?) -> some View { private func reading(_ title: String, _ text: String) -> some View {
VStack(spacing: 2) { VStack(spacing: 2) {
Text(value.map { String(format: "%.1f°", $0) } ?? "") Text(text)
.font(.title2.weight(.semibold).monospacedDigit()) .font(.title2.weight(.semibold).monospacedDigit())
Text(title) Text(title)
.font(.caption) .font(.caption)
+2 -2
View File
@@ -31,9 +31,9 @@ struct WatchBubble: View {
.fill(color) .fill(color)
.frame(width: bubble, height: bubble) .frame(width: bubble, height: bubble)
// Positiver Pitch heisst: das Heck steht höher, die Blase // Positiver Pitch heisst: das Heck steht höher, die Blase
// wandert nach oben in der Ansicht also nach hinten. // wandert nach unten in der Ansicht also nach hinten.
.offset(x: clamped(roll) / range * travel, .offset(x: clamped(roll) / range * travel,
y: -clamped(pitch) / range * travel) y: clamped(pitch) / range * travel)
.animation(.spring(duration: 0.3), value: pitch) .animation(.spring(duration: 0.3), value: pitch)
.animation(.spring(duration: 0.3), value: roll) .animation(.spring(duration: 0.3), value: roll)
.opacity(pitch == nil && roll == nil ? 0.25 : 1) .opacity(pitch == nil && roll == nil ? 0.25 : 1)
@@ -0,0 +1,30 @@
import Foundation
/// Formatiert Neigungswerte mit vorangestelltem Richtungsbuchstaben statt
/// Vorzeichen "H 1.8°" statt "1.8°", "L 0.9°" statt "-0.9°".
///
/// Gemeinsam für App und Live-Activity-Widget, deshalb hier statt in
/// `Shared/`, das die Extension nicht mitkompiliert (siehe
/// `LevelActivityAttributes.swift`).
enum LevelDirectionFormatting {
/// "1.8°" Betrag ohne Vorzeichen, für Stellen, die die Richtung schon
/// im Wort oder Buchstaben ausdrücken.
static func magnitude(_ value: Double?) -> String {
guard let value else { return "" }
return String(format: "%.1f°", abs(value))
}
/// Längsneigung als Kachel: positiv (Heck höher) "H", negativ (Front
/// höher) "F". Muss zum Vorzeichen von `LevelState.pitch` passen.
static func pitchTile(_ value: Double?) -> String {
guard let value else { return "" }
return "\(value >= 0 ? "H" : "F") \(magnitude(value))"
}
/// Querneigung als Kachel: positiv (rechts höher) "R", negativ (links
/// höher) "L". Muss zum Vorzeichen von `LevelState.roll` passen.
static func rollTile(_ value: Double?) -> String {
guard let value else { return "" }
return "\(value >= 0 ? "R" : "L") \(magnitude(value))"
}
}
@@ -9,14 +9,6 @@ import ActivityKit
import WidgetKit import WidgetKit
import SwiftUI import SwiftUI
/// Formatiert Neigungswerte wie in der App °, eine Nachkommastelle, ""
/// wenn kein Messwert da ist. Eigene Kopie, da die Extension nicht von
/// `Shared/Models/Metric.swift` abhängt.
private func degreesText(_ value: Double?) -> String {
guard let value else { return "" }
return String(format: "%.1f°", value)
}
private struct LevelIcon: View { private struct LevelIcon: View {
let isLevel: Bool let isLevel: Bool
let hasReading: Bool let hasReading: Bool
@@ -73,8 +65,8 @@ private struct LevelBubbleGlyph: View {
.fill(bubbleColor) .fill(bubbleColor)
.frame(width: bubble, height: bubble) .frame(width: bubble, height: bubble)
// Positiver Pitch heisst: Heck steht höher, die Blase wandert // Positiver Pitch heisst: Heck steht höher, die Blase wandert
// also nach oben wie bei der echten Wasserwaage in der App. // also nach unten wie bei der echten Wasserwaage in der App.
.offset(x: offsetX, y: -offsetY) .offset(x: offsetX, y: offsetY)
.opacity(hasReading ? 1 : 0.3) .opacity(hasReading ? 1 : 0.3)
} }
.frame(width: diameter, height: diameter) .frame(width: diameter, height: diameter)
@@ -94,27 +86,24 @@ struct VanAligneiOSWidgetLiveActivity: Widget {
.activitySystemActionForegroundColor(.white) .activitySystemActionForegroundColor(.white)
} dynamicIsland: { context in } dynamicIsland: { context in
// Die Dynamic Island bleibt bewusst so klein wie möglich Libelle
// und Zahlenwerte gehören auf Sperrbildschirm/CarPlay, hier reicht
// ein Blick auf Icon und, aufgeklappt, die Kurzanweisung.
DynamicIsland { DynamicIsland {
DynamicIslandExpandedRegion(.leading) { DynamicIslandExpandedRegion(.leading) {
LevelBubbleGlyph(pitch: context.state.pitch, roll: context.state.roll, LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil)
isLevel: context.state.isLevel, diameter: 44) .font(.title3)
}
DynamicIslandExpandedRegion(.trailing) {
VStack(alignment: .trailing, spacing: 2) {
Text(degreesText(context.state.pitch)).monospacedDigit()
Text(degreesText(context.state.roll)).monospacedDigit()
}
.font(.headline)
} }
DynamicIslandExpandedRegion(.bottom) { DynamicIslandExpandedRegion(.bottom) {
Text(context.state.instruction ?? context.attributes.deviceName) Text(context.state.instruction ?? context.attributes.deviceName)
.font(.subheadline) .font(.caption)
.foregroundStyle(context.state.isLevel ? .green : .primary) .foregroundStyle(context.state.isLevel ? .green : .primary)
.lineLimit(1)
} }
} compactLeading: { } compactLeading: {
LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil) LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil)
} compactTrailing: { } compactTrailing: {
Text(degreesText(context.state.pitch)).monospacedDigit() EmptyView()
} minimal: { } minimal: {
LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil) LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil)
} }
@@ -130,40 +119,52 @@ private struct LockScreenLevelView: View {
let attributes: LevelActivityAttributes let attributes: LevelActivityAttributes
let state: LevelActivityAttributes.ContentState let state: LevelActivityAttributes.ContentState
var body: some View { /// Muss mit `LevelState.levelTolerance` übereinstimmen.
HStack(spacing: 16) { private let tolerance: Double = 0.5
LevelBubbleGlyph(pitch: state.pitch, roll: state.roll, isLevel: state.isLevel)
VStack(alignment: .leading, spacing: 4) { /// Längszeile, z.B. "Heck steht höher 1.8°" nur wenn ausserhalb der
/// Toleranz, genau wie bei `LevelState.instruction` in der App.
private var pitchLine: String? {
guard let pitch = state.pitch, abs(pitch) > tolerance else { return nil }
let label = pitch >= 0 ? "Heck steht höher" : "Front steht höher"
return "\(label) \(LevelDirectionFormatting.magnitude(pitch))"
}
/// Querzeile, z.B. "links steht höher 0.9°".
private var rollLine: String? {
guard let roll = state.roll, abs(roll) > tolerance else { return nil }
let label = roll >= 0 ? "rechts steht höher" : "links steht höher"
return "\(label) \(LevelDirectionFormatting.magnitude(roll))"
}
var body: some View {
HStack(spacing: 20) {
LevelBubbleGlyph(pitch: state.pitch, roll: state.roll, isLevel: state.isLevel, diameter: 64)
VStack(alignment: .leading, spacing: 6) {
Text(attributes.deviceName) Text(attributes.deviceName)
.font(.caption) .font(.caption)
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
Text(state.instruction ?? "Keine Messwerte")
.font(.headline) if pitchLine == nil && rollLine == nil {
Text(state.pitch == nil && state.roll == nil ? "Keine Messwerte" : "Steht eben")
.font(.title3.weight(.semibold))
.foregroundStyle(state.isLevel ? .green : .white) .foregroundStyle(state.isLevel ? .green : .white)
.lineLimit(2) } else {
if let pitchLine {
Text(pitchLine).font(.title3.weight(.semibold))
}
if let rollLine {
Text(rollLine).font(.title3.weight(.semibold))
}
}
} }
Spacer() Spacer(minLength: 0)
VStack(alignment: .trailing, spacing: 2) {
reading("Längs", state.pitch)
reading("Quer", state.roll)
}
} }
.padding(16) .padding(16)
.foregroundStyle(.white) .foregroundStyle(.white)
} }
private func reading(_ title: String, _ value: Double?) -> some View {
HStack(spacing: 6) {
Text(title)
.font(.caption2)
.foregroundStyle(.secondary)
Text(degreesText(value))
.font(.subheadline.monospacedDigit())
}
}
} }
extension LevelActivityAttributes { extension LevelActivityAttributes {