// // VanControlLiveActivity.swift // VanControlLiveActivity // // 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) } } /// 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 /// 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 private var hasReading: Bool { pitch != nil || roll != nil } private var bubbleColor: Color { guard let pitch, let roll else { return .white.opacity(0.4) } return levelColour(forDeviation: max(abs(pitch), abs(roll))) } 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(levelTolerance / 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 VanControlLiveActivity: Widget { var body: some WidgetConfiguration { ActivityConfiguration(for: LevelActivityAttributes.self) { context in LevelActivityContent(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) } .supplementalActivityFamilies([.small]) } } /// Wählt je nach Darstellungsgrösse zwischen Sperrbildschirm- und /// Kompaktansicht. /// /// 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 { let attributes: LevelActivityAttributes let state: LevelActivityAttributes.ContentState /// 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) > levelTolerance 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) > levelTolerance 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) { VanControlLiveActivity() } contentStates: { LevelActivityAttributes.ContentState.level LevelActivityAttributes.ContentState.tilted }