- 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>
196 lines
7.6 KiB
Swift
196 lines
7.6 KiB
Swift
//
|
||
// VanAligneiOSWidgetLiveActivity.swift
|
||
// VanAligneiOSWidget
|
||
//
|
||
// Created by Christopher Helberg on 02.09.26.
|
||
//
|
||
|
||
import ActivityKit
|
||
import WidgetKit
|
||
import SwiftUI
|
||
|
||
private struct LevelIcon: View {
|
||
let isLevel: Bool
|
||
let hasReading: Bool
|
||
|
||
var body: some View {
|
||
Image(systemName: hasReading ? (isLevel ? "checkmark.circle.fill" : "level") : "level")
|
||
.foregroundStyle(hasReading ? (isLevel ? .green : .orange) : .secondary)
|
||
}
|
||
}
|
||
|
||
/// Verkleinerte Libelle für Sperrbildschirm/CarPlay und die erweiterte
|
||
/// Dynamic Island – dieselbe Optik wie `LevelBubble` in der App, aber ohne
|
||
/// deren Abhängigkeit auf `Shared/Models/LevelState.swift`, das in der
|
||
/// Extension nicht mitkompiliert wird.
|
||
private struct LevelBubbleGlyph: View {
|
||
let pitch: Double?
|
||
let roll: Double?
|
||
let isLevel: Bool
|
||
var diameter: CGFloat = 54
|
||
|
||
/// Bis zu welcher Neigung die Blase ausschlägt, wie in der App-Libelle.
|
||
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 bubbleColor: Color {
|
||
guard let pitch, let roll else { return .white.opacity(0.4) }
|
||
let deviation = max(abs(pitch), abs(roll))
|
||
if deviation <= tolerance { return .green }
|
||
if deviation <= 2 { return .orange }
|
||
return .red
|
||
}
|
||
|
||
var body: some View {
|
||
let radius = diameter / 2
|
||
let bubble = diameter * 0.22
|
||
let travel = radius - bubble / 2 - 3
|
||
let offsetX = clamped(roll) / range * travel
|
||
let offsetY = clamped(pitch) / range * travel
|
||
let toleranceRadius = max(tolerance / range * travel, bubble * 0.55)
|
||
|
||
ZStack {
|
||
Circle()
|
||
.fill(Color.white.opacity(0.1))
|
||
Circle()
|
||
.strokeBorder(Color.white.opacity(0.3), lineWidth: 1)
|
||
Circle()
|
||
.strokeBorder(isLevel ? Color.green : Color.white.opacity(0.25),
|
||
lineWidth: isLevel ? 2 : 1)
|
||
.frame(width: toleranceRadius * 2, height: toleranceRadius * 2)
|
||
Circle()
|
||
.fill(bubbleColor)
|
||
.frame(width: bubble, height: bubble)
|
||
// Positiver Pitch heisst: Heck steht höher, die Blase wandert
|
||
// 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)
|
||
}
|
||
|
||
private func clamped(_ value: Double?) -> Double {
|
||
guard let value else { return 0 }
|
||
return min(max(value, -range), range)
|
||
}
|
||
}
|
||
|
||
struct VanAligneiOSWidgetLiveActivity: Widget {
|
||
var body: some WidgetConfiguration {
|
||
ActivityConfiguration(for: LevelActivityAttributes.self) { context in
|
||
LockScreenLevelView(attributes: context.attributes, state: context.state)
|
||
.activityBackgroundTint(Color(white: 0.08))
|
||
.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) {
|
||
LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil)
|
||
.font(.title3)
|
||
}
|
||
DynamicIslandExpandedRegion(.bottom) {
|
||
Text(context.state.instruction ?? context.attributes.deviceName)
|
||
.font(.caption)
|
||
.foregroundStyle(context.state.isLevel ? .green : .primary)
|
||
.lineLimit(1)
|
||
}
|
||
} compactLeading: {
|
||
LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil)
|
||
} compactTrailing: {
|
||
EmptyView()
|
||
} minimal: {
|
||
LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil)
|
||
}
|
||
.keylineTint(context.state.isLevel ? .green : .orange)
|
||
}
|
||
}
|
||
}
|
||
|
||
/// Sperrbildschirm- und Banner-Ansicht. Dieselbe Ansicht landet automatisch
|
||
/// auch im CarPlay-Dashboard, sobald das iPhone verbunden ist – ein eigenes
|
||
/// CarPlay-App-Target ist dafür nicht nötig.
|
||
private struct LockScreenLevelView: View {
|
||
let attributes: LevelActivityAttributes
|
||
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
|
||
/// 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)
|
||
|
||
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(minLength: 0)
|
||
}
|
||
.padding(16)
|
||
.foregroundStyle(.white)
|
||
}
|
||
}
|
||
|
||
extension LevelActivityAttributes {
|
||
fileprivate static var preview: LevelActivityAttributes {
|
||
LevelActivityAttributes(deviceName: "Nivellierung")
|
||
}
|
||
}
|
||
|
||
extension LevelActivityAttributes.ContentState {
|
||
fileprivate static var level: LevelActivityAttributes.ContentState {
|
||
LevelActivityAttributes.ContentState(pitch: 0.1, roll: -0.2, isLevel: true,
|
||
instruction: "Steht eben", isCalibrated: true,
|
||
updatedAt: .now)
|
||
}
|
||
|
||
fileprivate static var tilted: LevelActivityAttributes.ContentState {
|
||
LevelActivityAttributes.ContentState(pitch: 2.4, roll: -1.1, isLevel: false,
|
||
instruction: "Heck steht höher, links steht höher",
|
||
isCalibrated: true, updatedAt: .now)
|
||
}
|
||
}
|
||
|
||
#Preview("Notification", as: .content, using: LevelActivityAttributes.preview) {
|
||
VanAligneiOSWidgetLiveActivity()
|
||
} contentStates: {
|
||
LevelActivityAttributes.ContentState.level
|
||
LevelActivityAttributes.ContentState.tilted
|
||
}
|