diff --git a/CamperMonitor/Views/AlignmentAssistantView.swift b/CamperMonitor/Views/AlignmentAssistantView.swift index f2e6cf2..cfd1495 100644 --- a/CamperMonitor/Views/AlignmentAssistantView.swift +++ b/CamperMonitor/Views/AlignmentAssistantView.swift @@ -138,15 +138,15 @@ struct AlignmentAssistantView: View { private var readings: some View { HStack(spacing: 12) { - reading("Längs", state.pitch) - reading("Quer", state.roll) - reading("Gesamt", assistant.current?.deviation) + reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch)) + reading("Quer", LevelDirectionFormatting.rollTile(state.roll)) + 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) { - Text(value.map { String(format: "%.1f°", $0) } ?? "–") + Text(text) .font(.title2.weight(.semibold).monospacedDigit()) Text(title) .font(.caption) diff --git a/CamperMonitor/Views/DeviceCard.swift b/CamperMonitor/Views/DeviceCard.swift index 11d00f6..5883086 100644 --- a/CamperMonitor/Views/DeviceCard.swift +++ b/CamperMonitor/Views/DeviceCard.swift @@ -13,6 +13,8 @@ struct DeviceCard: View { if isShowingLastSettings, let 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 { HStack(alignment: .firstTextBaseline, spacing: 4) { 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 { let others = snapshot.metrics .filter { $0.id != snapshot.primaryMetric?.id && $0.value != nil } diff --git a/CamperMonitor/Views/LevelView.swift b/CamperMonitor/Views/LevelView.swift index 72c9170..165030b 100644 --- a/CamperMonitor/Views/LevelView.swift +++ b/CamperMonitor/Views/LevelView.swift @@ -76,8 +76,9 @@ struct LevelBubble: View { .fill(bubbleColor) .frame(width: bubble, height: bubble) // Positiver Pitch heisst: das Heck steht höher, die Blase - // wandert also nach oben – in der Ansicht nach hinten. - .offset(x: offsetX, y: -offsetY) + // wandert also nach unten – in der Ansicht nach hinten. + // Muss zur "Fahrzeug"-Ansicht (VehicleTiltView) passen. + .offset(x: offsetX, y: offsetY) .animation(.spring(duration: 0.35), value: offsetX) .animation(.spring(duration: 0.35), value: offsetY) .opacity(pitch == nil && roll == nil ? 0.25 : 1) @@ -138,8 +139,8 @@ struct LevelControls: View { } HStack(spacing: 24) { - reading("Längs", state.pitch) - reading("Quer", state.roll) + reading("Längs", LevelDirectionFormatting.pitchTile(state.pitch)) + reading("Quer", LevelDirectionFormatting.rollTile(state.roll)) } } .frame(maxWidth: .infinity) @@ -239,9 +240,9 @@ struct LevelControls: View { 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) { - Text(value.map { String(format: "%.1f°", $0) } ?? "–") + Text(text) .font(.title2.weight(.semibold).monospacedDigit()) Text(title) .font(.caption) diff --git a/CamperMonitorWatch/Views/WatchBubble.swift b/CamperMonitorWatch/Views/WatchBubble.swift index 65a9a87..922b5ff 100644 --- a/CamperMonitorWatch/Views/WatchBubble.swift +++ b/CamperMonitorWatch/Views/WatchBubble.swift @@ -31,9 +31,9 @@ struct WatchBubble: View { .fill(color) .frame(width: bubble, height: bubble) // 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, - y: -clamped(pitch) / range * travel) + y: clamped(pitch) / range * travel) .animation(.spring(duration: 0.3), value: pitch) .animation(.spring(duration: 0.3), value: roll) .opacity(pitch == nil && roll == nil ? 0.25 : 1) diff --git a/SharedActivity/LevelDirectionFormatting.swift b/SharedActivity/LevelDirectionFormatting.swift new file mode 100644 index 0000000..8be80a7 --- /dev/null +++ b/SharedActivity/LevelDirectionFormatting.swift @@ -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))" + } +} diff --git a/VanAligneiOSWidget/VanAligneiOSWidgetLiveActivity.swift b/VanAligneiOSWidget/VanAligneiOSWidgetLiveActivity.swift index 48811a6..e33b655 100644 --- a/VanAligneiOSWidget/VanAligneiOSWidgetLiveActivity.swift +++ b/VanAligneiOSWidget/VanAligneiOSWidgetLiveActivity.swift @@ -9,14 +9,6 @@ import ActivityKit import WidgetKit 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 { let isLevel: Bool let hasReading: Bool @@ -73,8 +65,8 @@ private struct LevelBubbleGlyph: View { .fill(bubbleColor) .frame(width: bubble, height: bubble) // Positiver Pitch heisst: Heck steht höher, die Blase wandert - // also nach oben – wie bei der echten Wasserwaage in der App. - .offset(x: offsetX, y: -offsetY) + // also nach unten – wie bei der echten Wasserwaage in der App. + .offset(x: offsetX, y: offsetY) .opacity(hasReading ? 1 : 0.3) } .frame(width: diameter, height: diameter) @@ -94,27 +86,24 @@ struct VanAligneiOSWidgetLiveActivity: Widget { .activitySystemActionForegroundColor(.white) } 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 { DynamicIslandExpandedRegion(.leading) { - LevelBubbleGlyph(pitch: context.state.pitch, roll: context.state.roll, - isLevel: context.state.isLevel, diameter: 44) - } - DynamicIslandExpandedRegion(.trailing) { - VStack(alignment: .trailing, spacing: 2) { - Text(degreesText(context.state.pitch)).monospacedDigit() - Text(degreesText(context.state.roll)).monospacedDigit() - } - .font(.headline) + LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil) + .font(.title3) } DynamicIslandExpandedRegion(.bottom) { Text(context.state.instruction ?? context.attributes.deviceName) - .font(.subheadline) + .font(.caption) .foregroundStyle(context.state.isLevel ? .green : .primary) + .lineLimit(1) } } compactLeading: { LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil) } compactTrailing: { - Text(degreesText(context.state.pitch)).monospacedDigit() + EmptyView() } minimal: { LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil) } @@ -130,40 +119,52 @@ private struct LockScreenLevelView: View { let attributes: LevelActivityAttributes let state: LevelActivityAttributes.ContentState - var body: some View { - HStack(spacing: 16) { - LevelBubbleGlyph(pitch: state.pitch, roll: state.roll, isLevel: state.isLevel) + /// Muss mit `LevelState.levelTolerance` übereinstimmen. + private let tolerance: Double = 0.5 - 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) .font(.caption) .foregroundStyle(.secondary) - Text(state.instruction ?? "Keine Messwerte") - .font(.headline) - .foregroundStyle(state.isLevel ? .green : .white) - .lineLimit(2) + + 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) + } else { + if let pitchLine { + Text(pitchLine).font(.title3.weight(.semibold)) + } + if let rollLine { + Text(rollLine).font(.title3.weight(.semibold)) + } + } } - Spacer() - - VStack(alignment: .trailing, spacing: 2) { - reading("Längs", state.pitch) - reading("Quer", state.roll) - } + Spacer(minLength: 0) } .padding(16) .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 {