// // VanAligneiOSWidgetLiveActivity.swift // VanAligneiOSWidget // // Created by Christopher Helberg on 02.09.26. // 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 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 oben – 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 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) } DynamicIslandExpandedRegion(.bottom) { Text(context.state.instruction ?? context.attributes.deviceName) .font(.subheadline) .foregroundStyle(context.state.isLevel ? .green : .primary) } } compactLeading: { LevelIcon(isLevel: context.state.isLevel, hasReading: context.state.pitch != nil) } compactTrailing: { Text(degreesText(context.state.pitch)).monospacedDigit() } 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 var body: some View { HStack(spacing: 16) { LevelBubbleGlyph(pitch: state.pitch, roll: state.roll, isLevel: state.isLevel) VStack(alignment: .leading, spacing: 4) { Text(attributes.deviceName) .font(.caption) .foregroundStyle(.secondary) Text(state.instruction ?? "Keine Messwerte") .font(.headline) .foregroundStyle(state.isLevel ? .green : .white) .lineLimit(2) } Spacer() VStack(alignment: .trailing, spacing: 2) { reading("Längs", state.pitch) reading("Quer", state.roll) } } .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 { 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 }